앱에서 테스트 가능한 가장 작은 소프트웨어를 실행해 예상대로 동작하는지 확인하는 테스트
단위테스트 대상은 컴포넌트 뿐만 아니라 유틸 함수, 리액트 훅도 해당한다.
https://github.com/practical-fe-testing/test-example-shopping-mall 레포지토리 shopping-mall-unit-test 브랜치 참조
EmptyNotice.jsx
import { Typography, Box, Link as MuiLink } from '@mui/material';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { pageRoutes } from '@/apiRoutes';
const EmptyNotice = () => {
const navigate = useNavigate();
const handleClickBack = () => {
navigate(pageRoutes.main);
};
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
height: 400,
alignItems: 'center',
flexDirection: 'column',
}}
>
<Typography sx={{ fontSize: '50px', fontWeight: 'light' }}>
텅~
</Typography>
<MuiLink
underline="hover"
onClick={handleClickBack}
style={{ cursor: 'pointer' }}
role="link"
>
홈으로 가기
</MuiLink>
</Box>
);
};
export default EmptyNotice;
장바구니에 추가한 항목이 없을때 보여주는 페이지로 단순한 ui로 구성되어있어 단위 테스트로 진행하기에 좋아보인다. 하지만, react-router-dom에서 useNavigate훅을 사용하고 있어 의존성이 존재한다.
독립적인 컴포넌트 단위 테스트에서 의존성이 존재할 경우 어떻게 테스트를 진행해야할까?
react, react-router-dom 같은 범용적인 라이브러리는 내부적으로 굉장히 상세하기 핵심기능을 단위통합 테스트로 검증한 상태이다. 따라서 useNavigate()에 대한 추가 검증은 불필요하다고 볼 수 있다.
그럼 어떻게 하라는거야?
단위 테스트에서 외부 모듈에 대한 검증은 완전히 분리하고 모듈의 특정 기능을 제대로 호출하는지만 검증하면 되는데, 여기서 바로 모킹이라는 개념이 등장한다.
모킹을 사용한다면
외부 모듈과 의존성을 제외한 필요부분만 검증이 가능
과 같은 장점
- 실제 모듈과 완전히 동일한 모의 객체를 구현하는 것은 큰 비용이 들어간다
- 모의 객체를 남용하는 것은 테스트 신뢰성을 낮춤
과 같은 단점이 존재한다.
그럼 모킹에 대해 알아봤으니 이전에 작성한 EmptyNotice 컴포넌트의 단위 테스트를 해보자
// 실제 모듈을 모킹한 모듈로 대체하여 테스트 실행
// useNavigate 훅으로 반환받은 navigate 함수가 올바르게 호출되었는가 -> 스파이 함수
const navigateFn = vi.fn();
vi.mock('react-router-dom', async () => {
const original = await vi.importActual('react-router-dom');
return {
...original,
useNavigate: () => navigateFn,
};
});
it('"홈으로 가기" 링크를 클릭할경우 "/"경로로 navigate함수가 호출된다', async () => {
const { user } = await render(<EmptyNotice />);
await user.click(screen.getByText('홈으로 가기'));
// 1번만 호출이 되는지?
expect(navigateFn).toHaveBeenNthCalledWith(1, '/');
});
테스트 선언하기 전, importActual을 사용하여 react-router-dom 중 useNavigate 훅을 모킹해서 실제로 해당 훅이 호출되는지 toHaveBeenNthCalledWith로 확인하는 모습이다.
비슷한 케이스로 에러 페이지에서 뒤로가기 버튼이 제대로 동작하는지 테스트를 작성해본다.
ErrorPage
import { screen } from '@testing-library/react';
import React from 'react';
import ErrorPage from '@/pages/error/components/ErrorPage';
import render from '@/utils/test/render';
// 실제 모듈을 모킹한 모듈로 대체하여 테스트 실행
// useNavigate 훅으로 반환받은 navigate 함수가 올바르게 호출되었는가 -> 스파이 함수
const navigateFn = vi.fn();
vi.mock('react-router-dom', async () => {
const original = await vi.importActual('react-router-dom');
return {
...original,
useNavigate: () => navigateFn,
};
});
it('"뒤로 이동" 버튼 클릭시 뒤로 이동하는 navigate(-1) 함수가 호출된다', async () => {
const { user } = await render(<ErrorPage />);
const button = await screen.getByRole('button', { name: '뒤로 이동' });
await user.click(button);
expect(navigateFn).toHaveBeenNthCalledWith(1, -1);
});
뒤로 이동으로 설정된 name을 보유한 버튼을 찾고 뒤로가기 버튼을 클릭했을때 useNavigate가 제대로 호출되는지 테스트한다. 이때 뒤로 버튼 클릭시 이동 경로가 -1이므로 1번 클릭했을때 -1로 호출이 되는지 확인하는 구문이다.
404페이지의 경우
// 실제 모듈을 모킹한 모듈로 대체하여 테스트 실행
// useNavigate 훅으로 반환받은 navigate 함수가 올바르게 호출되었는가 -> 스파이 함수
const navigateFn = vi.fn();
vi.mock('react-router-dom', async () => {
const original = await vi.importActual('react-router-dom');
return {
...original,
useNavigate: () => navigateFn,
};
});
it('Home으로 이동 버튼 클릭시 홈 경로로 이동하는 navigate가 실행된다', async () => {
const { user } = await render(<NotFoundPage />);
const button = await user.click(
screen.getByRole('button', { name: 'Home으로 이동' }),
);
await user.click(button);
expect(navigateFn).toHaveBeenNthCalledWith(1, '/', { replace: true });
});
useNavigate를 호출했을때 파라미터로 replace를 추가했기 때문에 해당 내용도 추가해준다.
모킹을 사용한 테스트가 있는 경우, 다른 테스트에서 특정 모듈에 대한 모칭이 필요없거나, 해당 모킹작업이 다른 테스트에 영향을 주게 된다면, 테스트의 신뢰성이 떨어지게 되므로, 모킹 초기화 는 필수적으로 진행한다.
방법은 간단하다. vi.clearAllMocks()를 작성한 teardown을 작성해주면 된다.
afterEach(() => {
// 런타임 요청 핸들러 제거
server.resetHandlers();
vi.clearAllMocks();
});
afterAll로 모킹 모듈에 대한 구현도 초기화 해주면 좋다.
afterAll(() => {
vi.resetAllMocks();
server.close();
});