최근 일을 하면서 테스트 코드의 비중을 점차 늘려나가고 있는데 이렇게 테스트 코드를 작성하면서 고민했던 것들을 정리해봅니다.
일반적인 테스팅 실수를 하지말자
testing-library, jest-dom에 대한 eslint 공식 플러그인이 존재합니다.
어느정도 테스트 코드를 짜보다보면 "당연한 거 아니야?"라는 생각이 들 수 있지만 처음 짤 때는 헷갈리기 마련이죠
이런 상황에서 실수를 방지하는 역할을 해줍니다.
https://github.com/testing-library/eslint-plugin-testing-library
https://github.com/testing-library/eslint-plugin-jest-dom
yarn add -D eslint-plugin-jest-dom eslint-plugin-testing-library
eslintrc.json
"plugins": ["testing-library", "jest-dom"],
"extends": [
"plugin:jest-dom/recommended",
"plugin:testing-library/react"
],
대체로 많이 실수하는 async 관련 처리와 screen, userEvent 등 권장하는 형태의 메서드 사용을 권하는 규칙들로 이루어져있습니다.
https://kentcdodds.com/blog/common-mistakes-with-react-testing-library
해당 글에서 위 팁 말고도 일반적으로 실수하기 쉬운 테스트 코드 작성법을 많이 다루고 있으니 참고해보세요
테스트하기 좋은 구조로 코드를 작성하자
저같은 경우에는 UI 컴포넌트 테스트를 작성하는 것이 처음에는 상당히 어려웠던 기억이 있어요
사실 적절하게 컴포넌트 테스트를 하기위해서는 실제 프로덕션 환경과 유사한 환경을 제공해주어야하는데 이 환경을 설정하는 것만으로도 모든 힘이 다 빠지곤 했었거든요
예를 들면 리액트쿼리와 서스펜스를 사용하는 컴포넌트를 적절히 테스트하기 위해서는 다음과 같은 환경 제공이 필요해요
1. axios, fetch와 같은 데이터페칭 도구의 모킹
2. react-query의 프로바이더 환경과 서스펜스를 래퍼로 제공하기
3. 비동기적으로 렌더링되는 요소들을 잘 캐치하기 위해 findBy ~ 구문으로 요소를 쿼리하기
한번 해보고나면 별거 아니지만 처음에는 저 미션 하나하나들이 굉장히 어렵게 다가오는 것 같아요
그래서 저는 계산로직을 추출하여서 순수함수형태로 코드를 작성하고 우선 이를 검증하는 형태의 유닛테스트를 많이 작성했어요
그렇게 만들어야하는 데이터의 구조를 명확하게 계산하고, 만들고 테스트하는것만으로도 코드에 대한 자신감이 커지거든요
이렇게 순수함수형태로 작은 유닛테스트를 작성하는 것의 가장 큰 장점은 테스트를 작성하기가 쉽다는 것이에요
그만큼 테스트코드에 익숙하지않은 시기에도 쉽게 테스트코드를 작성할 수 있게되니 자신감이 붙는 효과가 있었어요
사이드이펙트는 waitFor안에 존재해선 안된다.
처음 테스트 코드를 작성하던 시기에는 테스트 코드의 성공을 위해 사이드 이펙트를 waitFor안에 넣곤했습니다.
그렇게하면 성공하는게 맞지만 비동기 등의 이슈로 인해 제대로 성공처리 되지 않는 코드들이 성공하는 것처럼 보여지거든요
예를 들면 이러한 코드를 예로 들 수 있습니다.
await waitFor(() => {
userEvent.click(screen.getByText(/안녕하세요/))
expect(mockedUpdateAccountBookmark).toHaveBeenCalled();
});
이러한 형태로 코드를 작성하게되면 얼핏 거짓음성이 나오던 코드가 동작하는 것처럼 보이기 쉽습니다.
그러나 waitFor는 성공할때까지 N 번의 호출 횟수를 가질 수 있습니다.
따라서 waitFor안에 사이드이펙트를 발생시킬 수 있는 유저행동을 집어넣게 되면
사이드이펙트가 여러번 실행되어 이후 테스트를 오염시킬 가능성이 있어요
waitFor로 쿼리하지말고 find로 쿼리하자
// ❌
const submitButton = await waitFor(() =>
screen.getByRole("button", { name: /submit/i })
);
// ✅
const submitButton = await screen.findByRole("button", { name: /submit/i });
아까 위에서 걸어두었던 링크에서도 다루는 내용인데요 해당 예제도 위 링크에서 슬쩍 가져와봤습니다.
find로 시작하는 메서드를 사용하면 async하게 쿼리를 하기 때문에 훨씬 코드가 간결해집니다.
사실 위 두줄의 코드는 근본적으로는 같지만 코드의 간결함, 에러메시지의 구체성을 생각했을 때
아래와 같이 find 형태로 쿼리하는 것이 좋아요
무엇을 테스트해야하는가?
테스트 코드를 작성하면서 가장 크게 고민이 되던 부분들은 바로 이러한 지점이었습니다.
처음에는 세세하게 코드의 동작 구석구석을 검증하는 형태로 코드를 작성했습니다.
이렇게 코드를 작성하면 테스트 커버리지가 높아져서 기분이 좋아지는 효과가 있어요
하지만 그만큼 코드를 수정하고 변경하는 과정에서 테스트 코드도 함께 수정해야하는 경우가 많아집니다.
리팩토링 내성이 약하다. 라고 표현할 수 있을 것 같아요
https://jojoldu.tistory.com/614
이에 대해 고민을 하던 중 위 블로그 글을 읽으면서 내부 구현 검증을 피하자. 는 대전제를 가지고 코드를 작성해나가니 어느정도 리팩토링 내성이 생겼다고 느꼈어요
결과를 검증하는 형태로 테스트 코드를 작성하면 리팩토링을 하게되어도 테스트 코드가 이전보다 잘 깨지지 않는 것을 체감할 수 있었습니다.
뭘로 쿼리하는게 좋을까?
다양한 쿼리 방식을 지원하지만 사실상 많이 쓰이는 것들은 정해져있는 것 같습니다.
ByRole, ByTestId, ByText 정도가 있을 것 같은데요.
물론 실제 사용자행동과 유사하게 테스트를 작성하기 위해서는 ByRole이 권장되지만 개인적으로는 약간 불편할 때가 많았습니다.
좀 더 어감이 좋거나 의미 전달이 분명해지는 워딩이 처음부터 생각나는 경우가 많지 않다보니
개발을 하면서 제가 문득 더 좋은 표현이 생각나거나 혹은 PM, 디자이너, 마케터 분들이 더 좋은 표현을 생각해오시거나 하는 경우가 많았어요
그러다보니 워딩을 많이 수정하게 되는데 byText, byRole로 코드를 작성한 경우 워딩을 수정함과 동시에 쿼리가 실패하니까 약간의 피로가 있었습니다.
물론 매우 중요한 워드이기 때문에 변할일이 없거나 변하면 안되는 경우에는 두 쿼리 방식이 좋은 점도 있지만 리팩토링 내성이 너무 약해지는 것 같다는 체감도 있었어요
그래서 주로 testId를 통하여 쿼리를 작성하는 식으로 하고 있는데 아직까지는 꽤 마음에 드는 방식입니다.
const Cta = ({ className, children, variant, ...props }: FixedBottomCtaProps) => {
return (
<button className={cn(ctaVariants({ variant }), className)} data-testid="cta" {...props}>
{children}
</button>
);
};
Cta.displayName = "Fixed-Cta";
저같은 경우에는 모바일에서 사용 될 것을 가정한 뷰를 주로 그리다보니 항상 바텀에 고정되어있는 cta 버튼을 사용하는 경우가 많아요
이러한 경우 Cta는 거의 무조건적으로 한 페이지의 화면에서 단 한개만 존재하게 되다보니 미리 테스트아이디를 부여해두는 것이 오히려 편리하게 작용하는 경우가 매우 많았습니다.
항상 한 화면에서 단 한번만 사용될 수 있는 UI 의 경우에는 이렇게 testid를 미리 부여해두는 전략도 괜찮은 것 같아요
Context API를 의존성 주입 도구로 사용하자. 그런데.. Provider Hell은..?
Context API를 의존성 주입의 도구로 사용하자는 방식은 이미 널리 퍼져있기도합니다.
Context API를 리렌더링 문제 없이 사용하기 위해서는 해야하는 작업들이 너무 귀찮다보니
사실상 상태관리 도구로서 사용하기에는 무리가 있지만 의존성 주입의 도구로 사용하게 될 때에는
이러한 문제에서 자유로워질 수 있는 것도 한 몫 하는 것 같아요
그런데 정작 이러한 Context API를 통한 의존성 주입을 계속 하다보면 이런 문제에 직면하게 됩니다.
1. 프로바이더가 너무 많아서 슬프다
2. 생각보다 context api를 위한 보일러 플레이트가 많다.
저는 위 두가지 문제를 해결하는 유틸함수들을 만드는 걸 통해 해결하고자 했는데
context api의 경우에는 이미 toss/slash에서 제 생각과 비슷한 시도를 해둔게 있더라구요
type Example = { hello: string };
const ExampleContext = createContext<null | Example>(null);
const useExampleContext = () => {
const value = useReactContext(ExampleContext);
if (!value) throw new Error("need provider");
return value;
};
이렇게 context를 적절하게 사용하기 위해선 이런 보일러플레이트가 필요하곤 합니다.
대체로 커스텀훅을 작성할 때가 많이 귀찮은데 toss/slash의 경우에는 다음과 같은 구현을 사용합니다.
/** @tossdocs-ignore */
import { createContext, ReactNode, useContext, useMemo } from 'react';
type ProviderProps<ContextValuesType> = (ContextValuesType & { children: ReactNode }) | { children: ReactNode };
export function buildContext<ContextValuesType extends object>(
contextName: string,
defaultContextValues?: ContextValuesType | null
) {
const Context = createContext<ContextValuesType | undefined>(defaultContextValues ?? undefined);
function Provider({ children, ...contextValues }: ProviderProps<ContextValuesType>) {
const value = useMemo(
() => (Object.keys(contextValues).length > 0 ? contextValues : null),
// eslint-disable-next-line react-hooks/exhaustive-deps
[...Object.values(contextValues)]
) as ContextValuesType;
return <Context.Provider value={value}>{children}</Context.Provider>;
}
function useInnerContext() {
const context = useContext(Context);
if (context != null) {
return context;
}
if (defaultContextValues != null) {
return defaultContextValues;
}
throw new Error(`\`${contextName}Context\` must be used within \`${contextName}Provider\``);
}
Provider.displayName = contextName + 'Provider';
return [Provider, useInnerContext] as const;
}
근데 이건 좀 투머치 같다고 해야할까요 그리고 value 프로퍼티가 사라지는게 마음에 안들었습니다.
import {
ComponentPropsWithoutRef,
ComponentType,
PropsWithChildren,
ReactNode,
createContext,
useContext as useReactContext,
} from "react";
export const provider = <T extends ComponentType<any>>(
Component: T,
prop: Omit<ComponentPropsWithoutRef<T>, "children">
): [T, ComponentPropsWithoutRef<T>] => [Component, prop as ComponentPropsWithoutRef<T>];
export const tree = <T extends ReturnType<typeof provider>>(providerTree: Array<T>) => {
return function Wrapper({ children }: PropsWithChildren): ReactNode {
return providerTree.reduceRight<ReactNode>((acc, [Provider, props]) => {
return <Provider {...props}>{acc}</Provider>;
}, children);
};
};
export const context = <T extends Record<string, unknown>>(initialValue: T | null) => {
const Context = createContext<T | null>(initialValue);
const useContext = () => {
const value = useReactContext(Context);
if (!value) {
throw new Error("should provid context");
}
return value;
};
return [Context.Provider, useContext] as const;
};
export const builder = { provider, tree, context };
그래서 저같은 경우에는 다음과 같은 유틸함수를 정의하고 사용합니다.
프로바이더헬 문제는 tree와 provider 함수를 통해서 해결하는 편인데 예제 코드로 보면 이해가 쉬우니 작성해볼게요
type Example = { hello: string };
const [ExampleProvider, useExample] = builder.context<Example>(null);
const Providers = builder.tree([builder.provider(ExampleProvider, { value: { hello: "world" } })]);
이렇게 코드가 굉장히 간결해지게되고 provider가 늘어나게되어도 계속 좁아지는 프로바이더를 따라가는게 아니라
const Providers = builder.tree([
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
builder.provider(ExampleProvider, { value: { hello: "world" } }),
]);
이렇게 작성할 수 있습니다.
이게 최선일지는 모르겠지만 저는 나름대로 만족하고 쓰고 있는 점이 만약 저 형태를 직접 작성한다고 하면
const Providers = ({children}:PropsWithChildren) => {
return <ExampleProvider value={{hello:'world'}}>{children}</ExampleProvider>
}
이러한 형태가 되게되는데 PropsWithChildren 을 import하고 이것저것 써주는게 참 귀찮더라구요
이런 부분을 해소해주는 면이 있어서 테스트코드 짤때에도 유용하게 사용하는 것 같습니다.
fetch를 인터페이스에 의존하게 하면 jest-fetch-mock 같은게 없어도 테스트할 수 있지 않을까?
서버 API와의 통신을 테스트하는 코드를 짤 때 가장 고통스러웠던 경험은 fetch를 모킹하는 일이었는데요
이게 좀 고통스럽다보니 알아서 모킹해주는 라이브러리들도 많이 나와있습니다.
import fetchMock from "jest-fetch-mock";
fetchMock.enableMocks();
저같은 경우에는 이렇게 jest-fetch-mock을 이용해서 fetch를 모킹해두는데 사실 꼭 fetch를 모킹하는 라이브러리를 안써도 되지 않을까?하는 생각이 들어서 이렇게 코드를 짜보기도 했습니다.
export type Fetch = (input: RequestInfo | URL, init?: RequestInit | undefined) => Promise<Response>;
export type FetchArg = [RequestInfo | URL, RequestInit | undefined];
export type FetchReturn = Promise<Response>;
이렇게 Fetch의 인터페이스를 타입으로 정의하고 해당 인터페이스를 받아서 사용하는 형태로 코드를 구성하면
테스트가 쉬워지지않을까? 라는 생각을 했는데
export const createAuthApiService = (
injectedFetch: Fetch
) => {
return {
requestOtp: async (body: { phone: string }) => {
const response = await injectedFetch(AUTH_END_POINT.requestOtp(), {
method: "POST",
body: JSON.stringify(body),
headers: {
"Content-Type": "application/json;charset=utf-8",
},
});
if (!response.ok) throw new Error("failed fetch");
const result = await response.json();
return result
}
}
예를 들면 이렇게 외부에서 fetch를 주입받아서 사용하는 코드를 작성하는 것이었습니다.
describe("sign-up api service test", () => {
let mockFetch: jest.Mock;
beforeEach(() => {
mockFetch = jest.fn();
});
it("request otp method test", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ count: 1 }),
});
const service = createAuthApiService(mockFetch);
const result = await service.requestOtp({ phone: "01055555555" });
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(result).toEqual({ count: 1 });
expect(mockFetch).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
body: JSON.stringify({ phone: "01055555555" }),
})
);
})
}
이러한 경우 다음과 같이 모킹된 페치를 주입하는 식으로 테스트를 수행할 수 있었습니다만..
jest-fetch-mock과 msw를 사용하는 게 테스트를 관리하는 입장에서 좀 더 편한 방식일 것 같다는 생각이 들더라구요
fetch를 그냥 생 jest.fn()으로 대체해버리면 msw가 의미없어지는 문제가 발생했습니다.
함수가 콜되는지 확인하기 위해서 보일러플레이트가 많아진다.
가장 최근에 고민하고 있는 내용인데 회사 업무에서 가장 중요한 동작은 특정 행동이 일어났을때 실행되어야하는 부수효과들이 모두 올바르게 실행되었는지를 확인하는 것입니다.
이러한 부분은 사람이 실수하기 쉽기 때문입니다. 단일원천으로 자동화하여 관리하는 것이 물론 좋겠지만 여의치가 않은게 아쉽네요
이러한 것들을 검증하기 위해 적절한 인수와 함께 특정 api 콜들이 제대로 이루어지는지 확인하기 위해서 함수 세부구현들을 테스트해야하는 경우가 많아졌고 그렇다보니 모킹과 의존성 주입에 대한 피로도가 높아지는 문제가 있었습니다.
어떻게 해야할지 아직 고민이에요
+ 최근에는 해결방법이 떠올랐습니다. 부수효과는 적절한 인수와 함께 콜되는지를 확인하는 것이 중요한 부분이기 때문에 미리 서비스 객체를 stub으로 만들어두고 테스트 프레임워크의 spyOn을 통해 필요한 부분에서 필요한 stub 객체만 spy 하면 개별 테스트에서의 복잡도가 크게 낮아졌습니다.
마치며
프론트엔드 개발을 시작한지 꽤 시간이 지난 것 같습니다. 어느덧 1년 6개월정도 프론트엔드 개발만 한 것 같은데요
그럼에도 불구하고 복잡한 기능을 어떻게 적절히 추상화할 수 있을까?
어떻게 변경이 용이하면서 좋은 구조를 유지할 수 있을까?
어떻게 테스트하기 쉬운 코드를 작성할 수 있을까?
테스트는 어떤 부분을 어떻게 테스트해야할까?
같은 영역들은 아직 어렵게 느껴지는 것 같아요
최근에는 CSS를 관리하는 방식, 디자인 컴포넌트들을 설계하는 방식들에 고민이 깊은데 이것도 근시일내로 다루어보도록 하겠습니다.
읽어주셔서 감사합니다.
'testcode' 카테고리의 다른 글
간단했던 모노레포 toBeInDocument 이슈 해결 (0) | 2024.01.07 |
---|---|
cypress와 jest 를 함께 사용하려하면 conflict이 날 수 있다. (2) | 2023.11.24 |
jest --coverage 옵션을 통해 커버리지 확인하기 (1) | 2023.10.29 |
react-query, zustand test jest 환경설정 승리한 썰 푼다 (2) | 2023.10.26 |
[jest] jest에서 안쓰는 import가 있으면 테스트 에러를 내요.. (0) | 2023.08.02 |