반응형
✅ 들어가기 전
- 테스트를 왜 작성하는가? 에 대해 다시 생각해 보기
- 요구 사항 분석과 철저한 이해 → 코드 설계
- 사용자 입장에서 코드를 작성 → 내부 구현보다는 인터페이스 위주의 코드 작성
- TDD의 본질은 빠른 피드백
- TDD는 아니지만, 각 컴포넌트가 수행하기를 기대했던 동작, 즉 요구 사항을 다시 생각해 보고, 테스트를 작성하자.
🤔 고민
- 폴더 구조
- 루트에 mocks, tests 폴더를 두고 내부에 프로젝트 구조 폴더를 나누기
- ✅ 각 폴더 내부에 mocks, tests 폴더 → 관련 있는 코드 가까이 두는 것이 유지보수 측면에 좋을 것으로 판단
- 깃허브에서 React, React Query, MSW, RTL, Vitest 등에서 테스트 폴더 구조를 살펴봤을 때 각 관련된 폴더 내부에 tests 폴더를 따로 관리
- 의미 있는 단위 테스트하기
- 각 컴포넌트는 많은 하위 컴포넌트들로 이루어져 있는 경우가 있다. 이때 테스트는 어떻게 역할을 분담할 것인가?
- 예를 들어 <Carousel /> 컴포넌트의 데이터 fetching 상태에 따른 Skeleton 렌더가 잘 되는지에 대한 테스트는 <Carousel/>에서 해야 할까? 상위 컴포넌트인 <MainContent />에서 해야할까?
- ✅ Carousel 컴포넌트가 데이터를 렌더 하는 로직을 가지고 있기 때문에 데이터 fetching 상태에 따라 Carousel 컴포넌트 테스트에서 Skeleton 또는 Carousel의 렌더를 확인
- 시각적 요소에 대한 테스트
- 최근, 글또 큐레이션에 공유된 글을 읽고 생각했던 내용을 바탕으로 스토리북 또는 E2E 도입 고려
- Vitest? Jest?
- Jest 또한 Vite 프로젝트를 위해 사용 가능하지만, Vite + Jest 설정보다 Vite + Vitest 설정이 간단하고, 호환도 잘 되기 때문에 Vitest 사용.
- Vitest 공식 문서에서 볼 수 있듯이, Vite + Vitest를 사용하면 개발, 빌드 및 테스트 환경을 vite.config.js 파일에 한 번에 정의할 수 있고, 각 프로세스에서 동일한 플러그인과 환경을 사용할 수 있는 장점이 있다. 이를 통해 애플리케이션의 일관성과 효율성을 보장할 수 있다.
🧭 테스트 코드 설계 및 규칙 정의하기
- 문서화의 목적
- 가독성 및 일관성 있는 코드 구조
- 실제 코드 로직을 보지 않고도 명세, 테스트 타깃의 속성명, 메서드명만으로 잘 읽힐 것
- 해당 블로그를 참고해 3 뎁스를 지키고, 대제목은 컴포넌트명(또는 파일명), 소제목은 유사한 성격끼리 #분류명으로 작성
- Vitest를 이용한 Unit 테스트
- isolated 된 테스트를 위해 테스트 로직에 영향을 미칠 수 있는 경우 mocking
- data 관련은 MSW
- 함수는 vi의 spying & fn
- vi.spyOn(): 객체의 메서드나 getter/setter를 mocking. 특정 함수가 호출되었는지 확인, 인자 확인하는 용도
- vi.fn(): 함수 mocking.
- isolated 된 테스트를 위해 테스트 로직에 영향을 미칠 수 있는 경우 mocking
💫 트러블슈팅
Vitest를 기존의 개인 프로젝트 NutriNotes에 적용하면서 발생했던 문제와 이를 해결한 내용들입니다.
문제 1: [Styled-component]extends styled component
- 문제: Cannot create styled-component for component 에러 발생
- styled component를 extend 해 사용할 때 순서가 잘못되는 경우 발생
- 예) 공통 컴포넌트 Button을 확장해 ConfirmButton을 생성했는데, CommonButton이 먼저 생성되는 경우
해결
// X
const CustomButtom = styled(CommonButton) ``;
// O
const CustomButtom = styled(props => <CommonButton {...props} />)``
문제 2: [Vitest] Context의 Provider를 찾지 못하는 문제
- 문제: 라이브러리의 Provider를 찾지 못하는 문제 발생
해결
- React Query, Recoil, useNavigate 등을 사용하려면 필요한 Provider로 감싼 wrapper 생성
- React Query: QueryClientProvider
- Recoil: RecoilRoot
- useNavigate: Router
- render의 wrapper 옵션 사용
// Context를 제공할 Provider를 가진 wrapper 생성
const createWrapper = () => {
const queryClient = new QueryClient();
return function ({ children }: { children: React.ReactNode }) {
return (
<RecoilRoot>
<QueryClientProvider client={queryClient}>
<Router>{children}</Router>
</QueryClientProvider>
</RecoilRoot>
);
};
};
// wrapper로 컴포넌트를 감싸서 render
it('renders carousel with navigation buttons', () => {
render(<Carousel category="balanced" />, {
wrapper: createWrapper(),
});
const carousel = screen.getByRole('region', { name: /balanced/i });
const navigationButtons = screen.getAllByRole('button', { name: /page$/i });
expect(carousel).toBeInTheDocument();
expect(navigationButtons).toHaveLength(2);
});
+ 추가 해결책
- 다양한 Provider가 필요한데, 이를 매번 import 하여 옵션 설정하려면 번거롭다. 한 번에 모든 테스트 이전에 실행해주고 싶다면?
// test-utils/testing-library-utils.tsx
import React from 'react';
import { render } from '@testing-library/react';
import { RecoilRoot } from 'recoil';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter as Router } from 'react-router-dom';
const createWrapper = () => {
const queryClient = new QueryClient();
return function ({ children }: { children: React.ReactNode }) {
return (
<RecoilRoot>
<QueryClientProvider client={queryClient}>
<Router>{children}</Router>
</QueryClientProvider>
</RecoilRoot>
);
};
};
type RenderParams = Parameters<typeof render>;
const renderWithContext = (ui: RenderParams[0], options?: RenderParams[1]) =>
render(ui, { wrapper: createWrapper(), ...options });
// re-export everything
export * from '@testing-library/react';
// overried render method
export { renderWithContext as render };
- 위처럼 커스텀 render 함수를 만들고, 패키지의 다른 내용들은 re-export 하여 사용
- @testing-library의 메서드를 사용할 때 해당 파일에서 import 하여 사용
// common/__tests__/Carousel.tests.tsx
it('renders carousel with navigation buttons', () => {
render(<Carousel category="balanced" />);
const carousel = screen.getByRole('region', { name: /balanced/i });
const navigationButtons = screen.getAllByRole('button', { name: /page$/i });
expect(carousel).toBeInTheDocument();
expect(navigationButtons).toHaveLength(2);
});
💡 [TypeScript] Parameters와 ReturnType 유틸 타입
- Parameters: 함수 매개변수 타입을 튜플 형태로 추출
- ReturnType: 함수 반환 타입 추출
const add = (a: number, b: number, c: number) => {
return a + b + c;
};
Parameters<typeof add>; // [number, number, number]
ReturnType<typeof add>; // number
문제 3 : [MSW] 쿼리 파라미터가 들어간 url로 요청
When providing request pathnames, make sure to exclude any query parameters.
MSW 공식 문서
- 문제: 쿼리 파라미터가 들어간 url로의 요청을 intercept 하도록 작성했더니 아래와 같은 경고. MSW는 url의 쿼리 파라미터를 제외하므로 삭제하라는 내용
해결
url path에서 쿼리 파라미터 삭제
import { http, HttpResponse } from 'msw';
// O: 해당 주소 사용
const EDAMA_BASE_URL = '<https://api.edamam.com/api/recipes/v2>';
// X: [WARN] 쿼리 파라미터를 삭제하세요 !
const EDAMA_FULL_URL = `https://api.edamam.com/api/recipes/v2?type=public&diet=balanced
export const handlers = [
http.get(EDAMA_BASE_URL, () => {
return HttpResponse.json([
{ ... }
]);
}),
];
💡 왜 쿼리스트링은 제외하나요?
Query parameters do not describe RESTful resources. Instead, they provide additional data to the server. Query parameters will be automatically stripped by MSW during the request matching and will have no effect.
쿼리 파라미터는 RESTful 한 리소스를 표현하는 것이 아니라, 서버에 추가적인 정보를 제공합니다. 쿼리 파라미터는 요청 경로 비교 시 제거되어 유효하지 않습니다.
💡 쿼리 파라미터 값을 사용하려면?
브라우저의 url.searchParams()를 가져와서 사용
http.get('/post', ({ request }) => { const url = new URL(request.url) // GET /post/diet=balanced → diet: "balanced" const id = url.searchParams.get('id') return HttpResponse.json({ id, title: 'The Empowering Limitation', }) })
문제 4: [Vitest] IntersectionObserver is not defined
- 문제: jsdom과 node에 IntersectionObserver가 없기 때문에 발생한 에러
해결
- Vitest 공식문서에서 알려주는 대로 vi.stubGlobal메서드를 사용해 모킹한 IntersectionObserver를 사용할 수 있도록 globalThis 객체에 넣어준다.
import { vi } from 'vitest'
const IntersectionObserverMock = vi.fn(() => ({
disconnect: vi.fn(),
observe: vi.fn(),
takeRecords: vi.fn(),
unobserve: vi.fn(),
}))
vi.stubGlobal('IntersectionObserver', IntersectionObserverMock)
- 이를 통해 IntersectionObserver 또는 window.IntersectionObserver으로 접근 가능
문제 5: handler 분류하기
- 기능/도메인에 따라 mocking 할 데이터가 다양하고, 이에 따라 핸들러를 해당 디렉터리에 분리해 사용. 어떻게 서버에 다른 핸들러를 가져와서 사용하지?
해결
- 핸들러를 넘겨주면 server.use 할 수 있는 helper 함수 사용
- server.use()는 런타임에 핸들러를 override
- 로딩이나 에러 발생시킬 때도 불러와서 사용 가능
// test-utils/msw-utils.tsx
import { server } from '../tools/tests/server';
type SetupHandlersProps = Parameters<typeof server.use>;
const setupHandlers = (handlers: SetupHandlersProps) => {
server.use(...handlers);
};
export { setupHandlers };
⚠️ 주의
- msw 핸들러가 반환하는 데이터 구조는 data fetching시 반환받는 데이터 구조와 동일해야 한다.
const hits = Array.from({ length: 50 }, () => recipe).map((recipe, idx) => ({
recipe,
_links: {
self: {
href: `https://api.edama.com/api/v2/abcde${idx}`,
},
},
}));
export const handlers = [
http.get(EDAMA_BASE_URL, () => {
return HttpResponse.json({
hits,
});
}),
];
생각
Udemy에서 테스트 관련 강의를 들으면서 만들어두었던 개인 프로젝트 NutriNotes에 도입해 보기 시작했습니다. 역시 강의 때의 에러 없이 돌아가던 테스트 코드와는 다른 경험을 할 수 있었습니다.
Vite 프로젝트에 Vitest를 도입하는 과정에서 각 함수나 컴포넌트의 요구 사항을 분석하고, 컴포넌트가 유저들과 어떻게 상호작용해야 하는지를 다시 한번 생각해 볼 수 있었습니다. 또한, role로 요소들을 찾고 테스트하는 과정에서 접근성을 개선할 수 있었습니다. 예를 들어, 캐러셀 슬라이드를 넘기는 버튼이 react-icon이었는데, role을 가지고 있지 않았습니다. 따라서 해당 요소에 role="button"을 주고, title 속성에 "이전 버튼", "다음 버튼" 값을 주어 스크린리더에서도 읽힐 수 있도록 개선하였습니다.
앞으로 해당 프로젝트에 지속적으로 테스트를 적용하면서 각 컴포넌트가 자신의 역할을 충실히 수행하고, 높은 접근성을 제공하도록 개선해 나갈 예정입니다.
REF
반응형
'FrontEnd > Test' 카테고리의 다른 글
프론트엔드 테스트 어떤 순서로, 무엇을 테스트하면 좋을까? (23) | 2024.03.31 |
---|---|
Vite, TypeScript, React Testing Library, Jest 설정하기 - (2) React 테스트 환경 설정 및 Jest 에러 해결 (32) | 2024.01.07 |
Vite, TypeScript, React Testing Library, Jest 설정하기 - (1) 각 파일 설정 이해하기 (1) | 2023.12.24 |