PAZIZIC TV 프로젝트에서 비디오 카드 컴포넌트를 대상으로 테스트 코드를 작성해보기에 앞서
테스트 환경을 알아보자.
1. 테스트 환경 설정
"devDependencies": {
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"react-test-renderer": "^18.3.1",
"@types/jest": "^29.5.12",
}
각 라이브러리의 역할, 사용할 수 잇는 메서드, 그리고 무엇을 테스트하기 위해 필요한지 알아보자.
1. @testing-library/jest-dom
설명:
- Jest의 기본 매처에 추가적인 DOM 관련 매처를 제공하고, 테스트에서 DOM 요소의 상태를 검사할 때 사용
사용 가능한 메서드:
- toBeInTheDocument(): 요소가 DOM에 존재하는지 확인
- toHaveTextContent(): 요소의 텍스트 내용이 특정 값과 일치하는지 확인
- toBeVisible(): 요소가 화면에 보이는지 확인
테스트 목적:
- UI 요소의 존재 여부, 텍스트 내용, 가시성 등을 검증할 수 있음.
2. @testing-library/react
설명:
- React 컴포넌트를 테스트하기 위한 라이브러리로, 컴포넌트를 렌더링하고 사용자 상호작용을 모사할 수 있는 API를 제공
사용 가능한 메서드:
- render(): 컴포넌트를 렌더링.
- screen.getByText(), screen.getByRole(), screen.getByTestId(): 특정 요소를 찾기 위한 쿼리 메서드.
- cleanup(): 테스트 후에 DOM을 정리.
테스트 목적:
- 컴포넌트의 렌더링 결과, 요소의 존재 여부, 사용자 상호작용 등을 검증할 수 있음.
3. @testing-library/user-event
설명:
- 실제 사용자 이벤트를 시뮬레이션하기 위한 라이브러리입니다. 사용자 행동을 더 자연스럽게 모사할 수 있음.
사용 가능한 메서드:
- userEvent.click(): 요소를 클릭.
- userEvent.type(): 입력 필드에 텍스트를 입력.
- userEvent.selectOptions(): 셀렉트 박스에서 옵션을 선택.
테스트 목적:
- 사용자 인터랙션을 테스트하고, 해당 인터랙션이 UI에 미치는 영향을 검증할 수 있음.
4. react-test-renderer
설명:
- React 컴포넌트를 렌더링하고, 그 결과를 스냅샷으로 저장하거나 테스트할 수 있는 라이브러리입니다. UI의 구조를 검사하는 데 유용.
사용 가능한 메서드:
- create(): 컴포넌트를 렌더링하고, 결과를 스냅샷으로 저장할 수 있음.
- toJSON(): 렌더링된 결과를 JSON 형태로 변환.
테스트 목적:
- 컴포넌트의 렌더링 결과가 예상과 일치하는지 확인하고, UI의 변화가 의도된 대로 이루어졌는지를 검증
5. @types/jest
설명:
- Jest의 타입 정의를 TypeScript에 제공하여, Jest의 메서드에 대한 타입 체크와 자동 완성을 지원.
사용 가능한 메서드:
- Jest의 모든 기본 매처(test, expect, describe 등)에 대한 타입 정보 제공.
테스트 목적:
- TypeScript를 사용하는 경우, 타입 안정성을 높이고 코드 작성 시의 효율성을 향상.
# VideoCard.jsx
import React from 'react';
import { formatAgo } from '../util/date';
import { useNavigate } from 'react-router-dom';
export default function VideoCard({ video, type }) {
const { title, thumbnails, channelTitle, publishedAt } = video.snippet;
const navigate = useNavigate();
const isList = type === 'list';
return (
<li
className={isList ? 'flex gap-1 m-2' : ''}
onClick={() => {
navigate(`/videos/watch/${video.id}`, { state: { video } });
}}
>
<img
className={
isList ? 'w-60 mr-2 cursor-pointer' : 'w-full cursor-pointer'
}
src={thumbnails.medium.url}
alt={title}
/>
<div>
<p className='font-semibold my-2 line-clamp-2 cursor-pointer'>
{title}
</p>
<p className='text-sm opacity-80'>{channelTitle}</p>
<p className='text-sm opacity-80'>{formatAgo(publishedAt, 'ko')}</p>
</div>
</li>
);
}
위의 비디오카드 컴포넌트는 video, type 데이터를 전달받고, 그 데이터를 li태그에서 보여주고 나서 전달받은 prop을 적적하게 보여주는 역할을 한다. 기능이라면 클릭 시 특정한 경로로 데이터를 가지고 이동하도록 하는 기능이 있다.
1. 렌더링 테스트
- 기본 렌더링 : 컴포넌트가 주어진 props에 따라 올바르게 렌더링되는지를 확인한다.
- type prop에 따라 클래스가 올바르게 적용되는지를 검증한다.
2. 요소 존재 여부 확인
- 타이틀, 채널명, 게시 날짜 : 컴포넌트가 각각의 요소를 올바르게 표시하는지 확인한다.
- 썸네일 이미지가 올바르게 렌더링되는지 확인한다.
3. 사용자 상호작용 테스트
- 클릭 이벤트 : 리스트 항목을 클릭했을 때 navigate 함수가 호출되는지를 검증한다. 이때, 올바른 경로와 상태가 잘 전달되는지 확인한다
4. 비주얼 테스트 (선택)
- 스냅샷 테스트 : 컴포넌트의 렌더링 결과를 스냅샷으로 저장하고, 이후 변화가 의도된 대로 이루어졌는지를 확인한다.
위 3가지를 중점적으로 테스트하면 될 것 같다.
우선
1. 렌더링 테스트
import { render } from '@testing-library/react';
import VideoCard from '../VideoCard';
const video = {
id: 1,
snippet: {
title: 'title',
channelId: '1',
channelTitle: 'channelTitle',
publishedAt: new Date(),
thumbnails: {
medium: {
url: 'http://image/',
},
},
},
};
describe('VideoCard', () => {
it('renders vieo item', () => {
render(<VideoCard video={video} />);
});
});
위의 테스트 코드로 테스트를 돌려보면,
useNavigate() may be used only in the context of a <Router> component.
와 같은 테스트 실패 메시지가 뜨는데, 리액트 라우터를 사용하는 컴포넌트를 만들 때는 리액트 라우터 환경을 만들어줘야 한다...
2. 요소 존재 여부 확인
import { fireEvent, render, screen } from '@testing-library/react';
import VideoCard from '../VideoCard';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { formatAgo } from '../../util/date';
const video = {
id: 1,
snippet: {
title: 'title',
channelId: '1',
channelTitle: 'channelTitle',
publishedAt: new Date(),
thumbnails: {
medium: {
url: 'http://image/',
},
},
},
};
const { title, thumbnails, channelTitle, publishedAt } = video.snippet;
describe('VideoCard', () => {
it('renders vieo item', () => {
render(
<MemoryRouter>
<VideoCard video={video} />
</MemoryRouter>
);
// 요소 존재 여부 확인
const image = screen.getByRole('img');
// expect(image.src).toBe(thumbnails.medium.url);
// expect(image.alt).toBe(title);
expect(image).toHaveAttribute('src', thumbnails.medium.url);
expect(image).toHaveAttribute('alt', title);
expect(screen.getByText(title)).toBeInTheDocument();
expect(screen.getByText(channelTitle)).toBeInTheDocument();
expect(screen.getByText(formatAgo(publishedAt, 'ko'))).toBeInTheDocument();
});
});
3. 사용자 상호작용 테스트
// onClick event test
it('navigates to detailed video page with vido state when clicked', () => {
function LocationStateDisplay() {
return <pre>{JSON.stringify(useLocation().state)}</pre>;
}
render(
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path='/' element={<VideoCard video={video} />} />
<Route
path={`/videos/watch/${video.id}`}
element={<LocationStateDisplay />}
/>
</Routes>
</MemoryRouter>
);
const card = screen.getByRole('listitem');
fireEvent.click(card);
expect(screen.getByText(JSON.stringify({ video }))).toBeInTheDocument();
});
# Import Statements
import { fireEvent, render, screen } from '@testing-library/react';
import VideoCard from '../VideoCard';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { formatAgo } from '../../util/date';
- fireEvent : 사용자 이벤트(클릭, 입력 등)를 시뮬레이션하기 위한 메서드
- render : 컴포넌트를 렌더링하여 테스트 환경을 추가
- screen : 렌더링된 컴포넌트에서 요소를 찾기 위한 유틸리티 메서드
- MemoryRouter : 라우팅을 테스트하기 위한 메모리 기반의 라우터이며, react-router-dom을 사용한 컴포넌틀 렌더링하기 위해 필요
- Route : 특정 경로에 해당하는 컴포넌트를 렌더링하기 위한 컴포넌트
- Routes : 여러 Route를 감싸고, 현재 URL에 맞는 Route를 선택
- useLocation : 현재 URL과 관련된 정보를 가져오는 React Router 훅
# Render the Component with Routes
render(
<MemoryRouter initialEntries={['/']}>
<Routes>
<Route path='/' element={<VideoCard video={video} />} />
<Route
path={`/videos/watch/${video.id}`}
element={<LocationStateDisplay />}
/>
</Routes>
</MemoryRouter>
);
- initialEntries : 메모리 라우터의 초기 경로를 설정
- Routes : 두 개의 경로를 정의한다. 첫 번째는 비디오 카드를 렌더링하고, 두 번째는 비디오 상세 페이지를 렌더링한다.
# Click Event Simulation
const card = screen.getByRole('listitem');
fireEvent.click(card);
- getByRole : 역할이 'listitem'인 요소 (비디오 카드)를 찾음
- fireEvent.click : 클릭 이벤트를 시뮬레이션하여 비디오 카드를 클릭
# State Check
expect(screen.getByText(JSON.stringify({ video }))).toBeInTheDocument();
- 클릭 후 이동된 페이지에서 상태가 올바르게 전달되었는지를 확인한다. 여기서는 비디오 데이터가 JSON 문자열로 변환된 형태가 문서에 존재하는지를 확인한다.
4. navigate 메서드 테스트
첫 번째 방법
it('navigates to detailed video page with video state when clicked', () => {
function LocationStateDisplay() {
return <pre>{JSON.stringify(useLocation().state)}</pre>;
}
render(
withRouter(
<>
<Route path='/' element={<VideoCard video={video} />} />
<Route
path={`/videos/watch/${video.id}`}
element={<LocationStateDisplay />}
/>
</>
)
);
const card = screen.getByRole('listitem');
fireEvent.click(card);
expect(screen.getByText(JSON.stringify({ video }))).toBeInTheDocument();
});
- 작성 의도 및 설명
# 목적 : 이 테스트는 VideoCard 컴포넌트를 클릭했을 때, 올바른 비디오 상태와 함께 상세 비디오 페이지로 이동하는지를 확인한다.
# LocationStateDisplay 함수 : 이 함수는 현재 위치의 상태를 보여주는 컴포넌트이다. 클릭 후에 상태가 올바르게 전달되었는지 확인하기 위해 사용됨
# render 함수 : VideoCard와 상세 페이지를 렌더링하여 서로 연결된 경로를 설정한다.
# fireEvent.click(card) : 실제로 카드 요소를 클릭하는 동작을 시뮬레이션한다.
# expect 문 : 클릭 후 상세 페이지에 비디오 상태가 올바르게 표시되는지 확인한다.
두 번째 방법
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
describe('비디오 카드 테스트할거다.', () => {
it('li 요소의 클릭 기능 테스트', () => {
const mockNavigate = jest.fn(); // navigate 함수를 mock으로 선언
useNavigate.mockReturnValue(mockNavigate); // mockNavigate를 반환하도록 설정
render(
<MemoryRouter>
<VideoCard video={video} />
</MemoryRouter>
);
const card = screen.getByRole('listitem');
fireEvent.click(card);
// navigate 함수가 올바르게 호출되었는지, 그리고 전달된 state 확인
expect(mockNavigate).toHaveBeenCalledWith(`/videos/watch/${video.id}`, {
state: { video },
});
});
});
- 작성 의도 및 설명
# 목적 : 이 테스트는 클릭했을 때 useNavigate가 올바르게 호출되는지를 확인한다. 즉, 비디오 상세 페이지로 이동하는 기능이 잘 작동하는지를 테스트한다.
# jest.mock : react-router-dom의 useNavigate를 모킹하여 실제 함수를 사용하지 않고도 테스트를 진행할 수 있도록 한다.
# mockNavigate : 모킹된 navigate 함수로, 이 함수가 호출되는지 여부를 확인할 수 있다.
# MemoryRouter : 라우팅을 위한 메모리 기반 라우터를 사용하여 테스트 환경을 설정한다.
# expect 문 : 클릭 후 mockNavigate가 올바른 인자와 함께 호출되었는지를 검증한다.
두 방법의 차이점
1. 테스트 초점
- 첫 번째 방법 : 비디오 카드 클릭 후 실제로 상태가 상세 페이지에 잘 전달되는지를 확인한다. 즉, 결과를 확인하는 테스트이다.
- 두 번째 방법 : 클릭했을 때 useNavigate가 올바르게 호출되는지를 확인한다. 즉, 함수 호출을 확인하는 테스트이다.
2. 구현 방식
- 첫 번째 방법 : 실제 라우팅을 시뮬레이션하여 결과를 확인
- 두 번째 방법 : useNavigate를 모킹하여 함수 호출을 추적한다. 실제 라우팅을 사용하지 않으므로 더 빠르고 간단한 테스트이다.
첫 번째 방법은 결과를 확인하는 테스트에 중점을 두고, 두 번째 방법은 함수 호출을 확인하는 테스트에 중점을 두고 있다.
# 좀 더 자세하게 분석하기
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
1. jest.mock(...)
- jest.mock은 Jest 테스트 프레임워크에서 특정 모듈(여기서는 react-router-dom)을 모킹하는 함수이다.
2. 'react-router-dom'
- 모킹할 모듈의 이름이다. 여기서는 리액트 애플리케이션에서 라우팅 기능을 제공하는 라이브러리이다.
3. { ...jest.requireActual ( 'react-router-dom' ) }
- jest.requireActual은 실제 react-router-dom 모듈의 모든 기능을 가져오는 함수이다. 이 기능들을 그대로 사용하고, ,그 위에 추가적인 변경을 할 수 있다.
4. useNavigate : jest.fn()
- useNavigate는 react-router-dom에서 제공하는 라우팅 기능 중 하나이다. 여기서는 이 함수를 모킹하여 jest.fn()으로 바꿔준다. jest.fn()은 Jest에서 제공하는, 호출된 횟수나 인자를 추적할 수 있는 가짜 함수이다.
- 이 코드는 react-router-dom이라는 라이브러리를 모킹하여, 실제 라우팅 기능을 사용하지 않고도 useNavigate 함수의 호출을 추적할 수 있게 해준다. 이렇게 하면 테스트를 더 쉽게 하고, 실제로 페이지를 전환하지 않고도 원하는 기능이 잘 작동하는지를 확인할 수 있다.
# VideoCard.test.js
import { fireEvent, render, screen } from '@testing-library/react';
import VideoCard from '../VideoCard';
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
import { formatAgo } from '../../util/date';
import { fakevideo as video } from '../../testS/videos';
import { withRouter } from '../../tests/utils';
import renderer from 'react-test-renderer';
const { title, thumbnails, channelTitle, publishedAt } = video.snippet;
describe('VideoCard', () => {
it('renders vieo item', () => {
render(
withRouter(<Route path='/' element={<VideoCard video={video} />} />)
);
// 요소 존재 여부 확인
const image = screen.getByRole('img');
// expect(image.src).toBe(thumbnails.medium.url);
// expect(image.alt).toBe(title);
expect(image).toHaveAttribute('src', thumbnails.medium.url);
expect(image).toHaveAttribute('alt', title);
expect(screen.getByText(title)).toBeInTheDocument();
expect(screen.getByText(channelTitle)).toBeInTheDocument();
expect(screen.getByText(formatAgo(publishedAt, 'ko'))).toBeInTheDocument();
});
// onClick event test
it('navigates to detailed video page with vido state when clicked', () => {
function LocationStateDisplay() {
return <pre>{JSON.stringify(useLocation().state)}</pre>;
}
render(
withRouter(
<>
<Route path='/' element={<VideoCard video={video} />} />
<Route
path={`/videos/watch/${video.id}`}
element={<LocationStateDisplay />}
/>
</>
)
);
const card = screen.getByRole('listitem');
fireEvent.click(card);
expect(screen.getByText(JSON.stringify({ video }))).toBeInTheDocument();
});
// type tests
it('applies correct class when type is "list"', () => {
render(
withRouter(
<Route path='/' element={<VideoCard video={video} type='list' />} />
)
);
const listItem = screen.getByRole('listitem');
expect(listItem).toHaveClass('flex gap-1 m-2'); // 'list' 타입일 때 클래스 확인
});
it('applies correct class when type is not "list"', () => {
render(
withRouter(
<Route path='/' element={<VideoCard video={video} type='grid' />} />
)
);
const listItem = screen.getByRole('listitem');
expect(listItem).not.toHaveClass('flex gap-1 m-2'); // 'list'가 아닐 때 클래스 확인
});
// snapshot test
it('renders grid type correctly', () => {
const component = renderer.create(
withRouter(<Route path='/' element={<VideoCard video={video} />} />)
);
expect(component.toJSON()).toMatchSnapshot();
});
it('renders list type correctly', () => {
const component = renderer.create(
withRouter(
<Route path='/' element={<VideoCard video={video} type='list' />} />
)
);
expect(component.toJSON()).toMatchSnapshot();
});
});
'TDD' 카테고리의 다른 글
테스트 코드 방식에 대한 고찰..테스트 코드 2가지 방식 (0) | 2024.09.17 |
---|---|
렌더링 테스트와 스냅샷 테스트에 대해 (1) | 2024.09.09 |
리액트에서 테스트는? (1) | 2024.09.05 |
TDD - stub 방식과 mock 방식 (2) | 2024.09.04 |
TDD 예제 2 (Dependency Injection)에 대해서 (3) | 2024.09.02 |