프론트엔드 프로젝트에 테스트를 적용하면서 가장 어렵게 느껴진 부분이 어떤 순서로, 무엇을 테스트하면 좋을지에 관한 것이었습니다. 이에 대해 Kent C. Dodds와 Tkdodo님의 블로그를 참고하여 이에 대한 생각을 정리해 보았습니다.
🤔 어떤 순서로, 무엇을 테스트할까?
테스트를 작성하는 목적 가운데 하나는 사용자, 즉 애플리케이션 사용자와 개발자가 애플리케이션을 사용할 때 애플리케이션이 제대로 동작할 것이라는 자신감을 주는 것입니다. 따라서 코드 커버리지 자체보다는 유저가 사용하는 유즈 케이스 모두를 코드가 지원하는지를 생각해보면 좋습니다.
무엇을 테스트하면 좋을까?
React에서는 어떠한 유즈 케이스가 있을까요? 크게 아래의 3가지 경우를 생각해 볼 수 있습니다.
- 라이프사이클 메서드: rerender
- 개발자가 해당 컴포넌트를 새로운 props로 리렌더 한다면?
- 컴포넌트가 리렌더링 하도록 새로운 context를 제공한다면?
- 이벤트 핸들러: userEvent
- 유저가 컴포넌트가 렌더 하는 요소와 상호작용할 수 있는지
- 컴포넌트 상태
- redux store, router에서 상태가 변경된다면?
어떤 순서로 하면 좋을까?
- 먼저 애플리케이션의 기능의 우선순위를 정합니다. 기준은 “애플리케이션에서 이 기능이 제대로 동작하지 않는다면 최악일 것 같다”는 순입니다.
- 에러나 예외가 발생하지 않는 “happy path”에 대한 E2E 테스트를 작성합니다.
- 엣지 케이스나 복잡한 비즈니스 로직에 대한 통합 테스트 및 유닛 테스트를 작성합니다.
🧪 TanStack Query 사용하는 컴포넌트, 어떻게 테스트할까?
TanStack Query의 useQuery를 사용해 데이터 fetching 하는 컴포넌트를 어떻게 테스트할지 고민해 봤을 때, 크게 아래 두 가지 정도를 생각해 볼 수 있었습니다.
- useQuery 커스텀 훅을 mocking 해서 각 data fetching state 별로 화면이 잘 렌더링 되는지 확인하는 유닛 테스트
- React Query의 내부 구현과 분리해 컴포넌트 렌더링 확인
- useQuery의 실제 메커니즘을 활용하여 데이터 레이어를 테스트한 후 이를 바탕으로 컴포넌트 렌더링을 확인하는 통합 테스트 ✅
- React Query의 내부 동작을 활용하여 보다 실제 production 환경과 가깝다.
1번의 경우가 유튜브나 블로그에 많이 올라와 있는 방식이었기 때문에 더 익숙할 수 있지만, 사실 TanStack Query의 메인테이너인 Tkdodo가 권장하는 방법은 2번의 통합테스트입니다. 그 이유는 테스트는 유저가 애플리케이션을 사용하는 방식으로 진행되어야 하기 때문입니다. useQuery의 메커니즘을 테스트하는 것이 아니라, 해당 메커니즘을 사용해 통합 테스트를 진행합니다.
아래와 같은 사항을 기억하면 좋습니다.
- 테스트는 유저가 애플리케이션을 사용하는 방식으로 진행되어야 한다.
- useQuery를 mocking 하는 것보다는 React Query 자체의 메커니즘을 사용해 통합 테스트를 진행한다.
- MSW와 같은 도구를 사용해 통합 테스트에 대한 API 응답을 mocking 하여 실제 백엔드에 의존하지 않도록 테스트한다.
- 정리하자면, 커스텀 훅이 제대로 된 데이터를 반환하는지 확인하고, 이를 바탕으로 화면에 제대로 렌더링 되는지를 확인하여 관심사 분리한다.
💫 React Query 사용하는 컴포넌트 테스트하기
사용 버전
msw ^2.0.13
vite ^5.0.11
@tanstack/react-query ^4.29.19
useQuery를 사용하는 컴포넌트의 data fetching 과정을 테스트하기 위해 생각해 볼 수 있는 경우는 크게 4가지입니다.
- 로딩 중인 경우, 로딩 Fallback UI 표시
- 에러 발생한 경우, 에러 Fallback UI 표시
- 데이터가 빈 배열인 경우, ‘No recipe Available’ 표시
- 데이터가 성공적으로 들어온 경우, 캐러셀 및 레시피 카드 표시
앞서 살펴본 것처럼 우선 useQuery를 사용하는 커스텀 훅이 예상한 대로 데이터를 올바르게 반환하는지 살펴보고, 이후 이에 따라 UI도 렌더링 되는지 테스트해 보겠습니다.
들어가기 전
React.Suspense 및 ErrorBoundary 사용하려면 라이브러리 Context를 사용하기 위해 Provider를 설정해 줬던 것과 마찬가지로 Provider로 필요한 코드를 감싸주고 suspense: true를 잊지 말고 설정해주어야 합니다.
// React-Query 설정
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true, // 여기
useErrorBoundary: true,
retry: false, // 에러 테스트 위해 필요
},
},
});
return function ({ children }: { children: React.ReactNode }) {
return (
<RecoilRoot>
<QueryClientProvider client={queryClient}>
<Router>{children}</Router>
</QueryClientProvider>
</RecoilRoot>
);
};
};
데이터 레이어 테스트
먼저, useQuery를 사용하는 커스텀 훅이 각 상황에 따라 예측된 데이터를 반환하는지 확인합니다.
테스트할 useCategorizedRecipesQuery는 카테고리를 인수로 받아 이를 쿼리 키로 하는 useQuery를 반환하는 커스텀 훅입니다.
export const categorizedRecipesQuery = (category: string) => ({
queryKey: [...categorizedRecipesKey, category],
queryFn: getRecipes(category),
});
const useCategorizedRecipes = (category: string) =>
useQuery({
...categorizedRecipesQuery(category),
select: (data: RecipeData) => processRecipesData(data),
staleTime: 1000 * 60 * 10,
});
아래와 같이 데이터를 성공적으로 반환할 경우와 에러가 발생할 경우를 나누어 반환되는 데이터를 확인합니다.
// useCategorizedRecipes.test.tsx
describe('useCategorizedRecipes', () => {
const recipeBaseURL = import.meta.env.VITE_EDAMAM_BASE_URL;
it('successful query hook returns recipe data', async () => {
// useQuery 사용하는 커스텀 훅 렌더
const { result } = renderHook(() => useCategorizedRecipes('balanced'));
// useQuery가 반환하는 data.isSuccess가 true가 될 때까지 기다린 후 data 가져오기
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const { data } = result.current;
// 예상한 객체와 같은지 확인
expect(data).toMatchObject({ recipeId: 1, type: 'Asian', ... });
});
it('failure query hook returns error', async () => {
// MSW의 반환값 덮어쓰기
server.use(
http.get(recipeBaseURL, async () => {
return new HttpResponse(null, { status: 404 });
}),
);
const { result } = renderHook(() => useCategorizedRecipes('balanced'));
// useQuery가 반환하는 data.isError가 true가 될 때까지 기다린 후 data 가져오기
await waitFor(() => expect(result.current.isError).toBe(true));
// error가 정의 되고, 에러 코드가 같은지 확인
expect(result.current.error).toBeDefined()
expect(result.current.response.status).toBe(404);
});
});
- renderHook에서 반환된 result 객체는 해당 커스텀 훅이 반환하는 값을 담고 있습니다. result.current로 반환값을 사용할 수 있습니다.
- 따라서 useQuery를 반환하는 useCategorizedRecipes의 result 객체는 useQuery가 반환하는 값과 같습니다.
UI 컴포넌트 테스트
확인된 데이터로 해당 커스텀 훅을 사용하는 컴포넌트가 예측된 대로 UI를 렌더링 하는지 확인합니다.
로딩 중
it('displays CarouselSkeleton when recipe data is loading', async () => {
server.use(
http.get(recipeBaseURL, async () => {
await delay();
return HttpResponse.json({ state: 200 });
}),
);
render(
<Suspense fallback={<CarouselSkeleton category="balanced" />}>
<Carousel category="balanced" />,
</Suspense>,
);
const carouselSkeleton = await screen.findByRole('region', { name: /skeleton/i });
expect(carouselSkeleton).toBeInTheDocument();
});
에러 발생 시
it('displays error message when too many requests', async () => {
server.use(
http.get(recipeBaseURL, () => {
return new HttpResponse(null, { status: 429 });
}),
);
render(
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Carousel category="balanced" />
</ErrorBoundary>,
);
const errorMessage = await screen.findByText('moving super fast!', { exact: false });
expect(errorMessage).toBeInTheDocument();
});
빈 배열인 경우
it('displays No Recipe Available when no data', async () => {
server.use(
http.get(recipeBaseURL, () => {
return HttpResponse.json([]);
}),
);
render(
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Carousel category="balanced" />
</ErrorBoundary>,
);
const noRecipeMessage = await screen.findByText('No Recipe Available');
expect(noRecipeMessage).toBeInTheDocument();
});
성공적으로 데이터 받은 경우
it('displays Carousel with RecipeCards when recipe data is ready', async () => {
render(<Carousel category="balanced" />);
const CAROUSEL_DATA_SIZE = 20;
const carousel = await screen.findByRole('region', { name: /recipes-carousel/i });
expect(carousel).toBeInTheDocument();
const recipeCards = await screen.findAllByRole('article');
expect(recipeCards).toHaveLength(CAROUSEL_DATA_SIZE);
})
REF
'FrontEnd > Test' 카테고리의 다른 글
프론트엔드 Vite 프로젝트에 Vitest 적용하기 (1) | 2024.01.20 |
---|---|
Vite, TypeScript, React Testing Library, Jest 설정하기 - (2) React 테스트 환경 설정 및 Jest 에러 해결 (32) | 2024.01.07 |
Vite, TypeScript, React Testing Library, Jest 설정하기 - (1) 각 파일 설정 이해하기 (1) | 2023.12.24 |
프론트엔드 프로젝트에 테스트를 적용하면서 가장 어렵게 느껴진 부분이 어떤 순서로, 무엇을 테스트하면 좋을지에 관한 것이었습니다. 이에 대해 Kent C. Dodds와 Tkdodo님의 블로그를 참고하여 이에 대한 생각을 정리해 보았습니다.
🤔 어떤 순서로, 무엇을 테스트할까?
테스트를 작성하는 목적 가운데 하나는 사용자, 즉 애플리케이션 사용자와 개발자가 애플리케이션을 사용할 때 애플리케이션이 제대로 동작할 것이라는 자신감을 주는 것입니다. 따라서 코드 커버리지 자체보다는 유저가 사용하는 유즈 케이스 모두를 코드가 지원하는지를 생각해보면 좋습니다.
무엇을 테스트하면 좋을까?
React에서는 어떠한 유즈 케이스가 있을까요? 크게 아래의 3가지 경우를 생각해 볼 수 있습니다.
- 라이프사이클 메서드: rerender
- 개발자가 해당 컴포넌트를 새로운 props로 리렌더 한다면?
- 컴포넌트가 리렌더링 하도록 새로운 context를 제공한다면?
- 이벤트 핸들러: userEvent
- 유저가 컴포넌트가 렌더 하는 요소와 상호작용할 수 있는지
- 컴포넌트 상태
- redux store, router에서 상태가 변경된다면?
어떤 순서로 하면 좋을까?
- 먼저 애플리케이션의 기능의 우선순위를 정합니다. 기준은 “애플리케이션에서 이 기능이 제대로 동작하지 않는다면 최악일 것 같다”는 순입니다.
- 에러나 예외가 발생하지 않는 “happy path”에 대한 E2E 테스트를 작성합니다.
- 엣지 케이스나 복잡한 비즈니스 로직에 대한 통합 테스트 및 유닛 테스트를 작성합니다.
🧪 TanStack Query 사용하는 컴포넌트, 어떻게 테스트할까?
TanStack Query의 useQuery를 사용해 데이터 fetching 하는 컴포넌트를 어떻게 테스트할지 고민해 봤을 때, 크게 아래 두 가지 정도를 생각해 볼 수 있었습니다.
- useQuery 커스텀 훅을 mocking 해서 각 data fetching state 별로 화면이 잘 렌더링 되는지 확인하는 유닛 테스트
- React Query의 내부 구현과 분리해 컴포넌트 렌더링 확인
- useQuery의 실제 메커니즘을 활용하여 데이터 레이어를 테스트한 후 이를 바탕으로 컴포넌트 렌더링을 확인하는 통합 테스트 ✅
- React Query의 내부 동작을 활용하여 보다 실제 production 환경과 가깝다.
1번의 경우가 유튜브나 블로그에 많이 올라와 있는 방식이었기 때문에 더 익숙할 수 있지만, 사실 TanStack Query의 메인테이너인 Tkdodo가 권장하는 방법은 2번의 통합테스트입니다. 그 이유는 테스트는 유저가 애플리케이션을 사용하는 방식으로 진행되어야 하기 때문입니다. useQuery의 메커니즘을 테스트하는 것이 아니라, 해당 메커니즘을 사용해 통합 테스트를 진행합니다.
아래와 같은 사항을 기억하면 좋습니다.
- 테스트는 유저가 애플리케이션을 사용하는 방식으로 진행되어야 한다.
- useQuery를 mocking 하는 것보다는 React Query 자체의 메커니즘을 사용해 통합 테스트를 진행한다.
- MSW와 같은 도구를 사용해 통합 테스트에 대한 API 응답을 mocking 하여 실제 백엔드에 의존하지 않도록 테스트한다.
- 정리하자면, 커스텀 훅이 제대로 된 데이터를 반환하는지 확인하고, 이를 바탕으로 화면에 제대로 렌더링 되는지를 확인하여 관심사 분리한다.
💫 React Query 사용하는 컴포넌트 테스트하기
사용 버전
msw ^2.0.13
vite ^5.0.11
@tanstack/react-query ^4.29.19
useQuery를 사용하는 컴포넌트의 data fetching 과정을 테스트하기 위해 생각해 볼 수 있는 경우는 크게 4가지입니다.
- 로딩 중인 경우, 로딩 Fallback UI 표시
- 에러 발생한 경우, 에러 Fallback UI 표시
- 데이터가 빈 배열인 경우, ‘No recipe Available’ 표시
- 데이터가 성공적으로 들어온 경우, 캐러셀 및 레시피 카드 표시
앞서 살펴본 것처럼 우선 useQuery를 사용하는 커스텀 훅이 예상한 대로 데이터를 올바르게 반환하는지 살펴보고, 이후 이에 따라 UI도 렌더링 되는지 테스트해 보겠습니다.
들어가기 전
React.Suspense 및 ErrorBoundary 사용하려면 라이브러리 Context를 사용하기 위해 Provider를 설정해 줬던 것과 마찬가지로 Provider로 필요한 코드를 감싸주고 suspense: true를 잊지 말고 설정해주어야 합니다.
// React-Query 설정
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true, // 여기
useErrorBoundary: true,
retry: false, // 에러 테스트 위해 필요
},
},
});
return function ({ children }: { children: React.ReactNode }) {
return (
<RecoilRoot>
<QueryClientProvider client={queryClient}>
<Router>{children}</Router>
</QueryClientProvider>
</RecoilRoot>
);
};
};
데이터 레이어 테스트
먼저, useQuery를 사용하는 커스텀 훅이 각 상황에 따라 예측된 데이터를 반환하는지 확인합니다.
테스트할 useCategorizedRecipesQuery는 카테고리를 인수로 받아 이를 쿼리 키로 하는 useQuery를 반환하는 커스텀 훅입니다.
export const categorizedRecipesQuery = (category: string) => ({
queryKey: [...categorizedRecipesKey, category],
queryFn: getRecipes(category),
});
const useCategorizedRecipes = (category: string) =>
useQuery({
...categorizedRecipesQuery(category),
select: (data: RecipeData) => processRecipesData(data),
staleTime: 1000 * 60 * 10,
});
아래와 같이 데이터를 성공적으로 반환할 경우와 에러가 발생할 경우를 나누어 반환되는 데이터를 확인합니다.
// useCategorizedRecipes.test.tsx
describe('useCategorizedRecipes', () => {
const recipeBaseURL = import.meta.env.VITE_EDAMAM_BASE_URL;
it('successful query hook returns recipe data', async () => {
// useQuery 사용하는 커스텀 훅 렌더
const { result } = renderHook(() => useCategorizedRecipes('balanced'));
// useQuery가 반환하는 data.isSuccess가 true가 될 때까지 기다린 후 data 가져오기
await waitFor(() => expect(result.current.isSuccess).toBe(true));
const { data } = result.current;
// 예상한 객체와 같은지 확인
expect(data).toMatchObject({ recipeId: 1, type: 'Asian', ... });
});
it('failure query hook returns error', async () => {
// MSW의 반환값 덮어쓰기
server.use(
http.get(recipeBaseURL, async () => {
return new HttpResponse(null, { status: 404 });
}),
);
const { result } = renderHook(() => useCategorizedRecipes('balanced'));
// useQuery가 반환하는 data.isError가 true가 될 때까지 기다린 후 data 가져오기
await waitFor(() => expect(result.current.isError).toBe(true));
// error가 정의 되고, 에러 코드가 같은지 확인
expect(result.current.error).toBeDefined()
expect(result.current.response.status).toBe(404);
});
});
- renderHook에서 반환된 result 객체는 해당 커스텀 훅이 반환하는 값을 담고 있습니다. result.current로 반환값을 사용할 수 있습니다.
- 따라서 useQuery를 반환하는 useCategorizedRecipes의 result 객체는 useQuery가 반환하는 값과 같습니다.
UI 컴포넌트 테스트
확인된 데이터로 해당 커스텀 훅을 사용하는 컴포넌트가 예측된 대로 UI를 렌더링 하는지 확인합니다.
로딩 중
it('displays CarouselSkeleton when recipe data is loading', async () => {
server.use(
http.get(recipeBaseURL, async () => {
await delay();
return HttpResponse.json({ state: 200 });
}),
);
render(
<Suspense fallback={<CarouselSkeleton category="balanced" />}>
<Carousel category="balanced" />,
</Suspense>,
);
const carouselSkeleton = await screen.findByRole('region', { name: /skeleton/i });
expect(carouselSkeleton).toBeInTheDocument();
});
에러 발생 시
it('displays error message when too many requests', async () => {
server.use(
http.get(recipeBaseURL, () => {
return new HttpResponse(null, { status: 429 });
}),
);
render(
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Carousel category="balanced" />
</ErrorBoundary>,
);
const errorMessage = await screen.findByText('moving super fast!', { exact: false });
expect(errorMessage).toBeInTheDocument();
});
빈 배열인 경우
it('displays No Recipe Available when no data', async () => {
server.use(
http.get(recipeBaseURL, () => {
return HttpResponse.json([]);
}),
);
render(
<ErrorBoundary FallbackComponent={ErrorFallback}>
<Carousel category="balanced" />
</ErrorBoundary>,
);
const noRecipeMessage = await screen.findByText('No Recipe Available');
expect(noRecipeMessage).toBeInTheDocument();
});
성공적으로 데이터 받은 경우
it('displays Carousel with RecipeCards when recipe data is ready', async () => {
render(<Carousel category="balanced" />);
const CAROUSEL_DATA_SIZE = 20;
const carousel = await screen.findByRole('region', { name: /recipes-carousel/i });
expect(carousel).toBeInTheDocument();
const recipeCards = await screen.findAllByRole('article');
expect(recipeCards).toHaveLength(CAROUSEL_DATA_SIZE);
})
REF
'FrontEnd > Test' 카테고리의 다른 글
프론트엔드 Vite 프로젝트에 Vitest 적용하기 (1) | 2024.01.20 |
---|---|
Vite, TypeScript, React Testing Library, Jest 설정하기 - (2) React 테스트 환경 설정 및 Jest 에러 해결 (32) | 2024.01.07 |
Vite, TypeScript, React Testing Library, Jest 설정하기 - (1) 각 파일 설정 이해하기 (1) | 2023.12.24 |