이번 편의 결과물: BookForm 컴포넌트에 대한 테스트 파일이 생기고 npm run test가 통과합니다. · 다루는 개념: Vitest 설정, React Testing Library 쿼리, 사용자 이벤트 시뮬레이션
이 편에서 만드는 파일
bookshelf/
├── package.json ~ (devDependencies·scripts.test 추가)
├── vite.config.js ~ (test 설정 추가)
└── src/
├── test/
│ └── setup.js + (jest-dom matcher 등록)
├── utils/
│ └── validateBookForm.js + (검증 로직을 컴포넌트 밖으로 분리)
└── components/
├── BookForm.jsx ~ (분리된 검증 함수 사용, role="alert" 추가)
└── BookForm.test.jsx + (Vitest + RTL 테스트)개념 정리
테스트를 작성하기 전에 검증 로직을 컴포넌트 밖으로 뺍니다. 로직이 컴포넌트 안에 있으면 렌더링까지 거쳐야만 테스트할 수 있지만, 순수 함수로 분리하면 입력과 출력만으로 검증할 수 있습니다. 이 원칙은 08편의 폼 검증에도 이미 적용할 수 있었던 내용이지만, 테스트를 실제로 붙여보는 이 편에서 그 효과가 분명해집니다.
React Testing Library는 DOM 구조가 아니라 사용자가 화면을 보는 방식으로 요소를 찾습니다. 쿼리 우선순위는 다음과 같습니다.
| 우선순위 | 쿼리 | 찾는 기준 | 예 |
|---|---|---|---|
| 1 | getByRole | 접근성 역할과 이름 | getByRole('button', { name: '저장' }) |
| 2 | getByLabelText | label과 연결된 입력 | getByLabelText('제목') |
| 3 | getByText | 화면에 보이는 텍스트 | getByText('제목을 입력하세요') |
| 최후 수단 | getByTestId | data-testid 속성 | 위 방법으로 구분이 안 될 때만 |
getByRole을 우선 쓰는 이유는 이 쿼리가 통과해야 스크린 리더 사용자도 같은 방식으로 요소를 찾을 수 있다는 뜻이기 때문입니다. 테스트가 접근성 확인을 겸합니다.
Vitest는 Vite 설정을 그대로 재사용하는 테스트 러너입니다. 브라우저 API(document, window)가 필요하므로 environment: 'jsdom'을 지정합니다.
실습
1. 파일 만들기 / 열기
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom을 실행한 뒤, src/test/setup.js, src/utils/validateBookForm.js, src/components/BookForm.test.jsx를 새로 만들고 vite.config.js, src/components/BookForm.jsx를 엽니다.
2. 코드 작성
package.json에 다음을 추가합니다(기존 필드는 유지).
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"vitest": "^2.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/user-event": "^14.0.0",
"jsdom": "^25.0.0"
}// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.js',
},
});// src/test/setup.js
import '@testing-library/jest-dom/vitest';// src/utils/validateBookForm.js
export function validateBookForm(values) {
const errors = {};
if (!values.title || !values.title.trim()) {
errors.title = '제목을 입력하세요';
}
if (!values.author || !values.author.trim()) {
errors.author = '저자를 입력하세요';
}
return errors;
}// src/components/BookForm.jsx
// 08편에서 만든 제출 상태 표시(useActionState)는 상위 페이지(NewBookPage)가 담당한다.
// BookForm 자신은 onSubmit·pending만 props로 받는 형태로 단순화해 테스트 대상을 좁힌다.
import { useState } from 'react';
import { validateBookForm } from '../utils/validateBookForm';
export default function BookForm({ onSubmit, pending }) {
const [values, setValues] = useState({ title: '', author: '' });
const [errors, setErrors] = useState({});
function handleChange(event) {
const { name, value } = event.target;
setValues((prev) => ({ ...prev, [name]: value }));
}
function handleSubmit(event) {
event.preventDefault();
const nextErrors = validateBookForm(values);
setErrors(nextErrors);
if (Object.keys(nextErrors).length === 0) {
onSubmit(values);
}
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="title">제목</label>
<input
id="title"
name="title"
value={values.title}
onChange={handleChange}
aria-invalid={Boolean(errors.title)}
aria-describedby={errors.title ? 'title-error' : undefined}
/>
{errors.title && (
<p id="title-error" role="alert">
{errors.title}
</p>
)}
</div>
<div>
<label htmlFor="author">저자</label>
<input
id="author"
name="author"
value={values.author}
onChange={handleChange}
aria-invalid={Boolean(errors.author)}
aria-describedby={errors.author ? 'author-error' : undefined}
/>
{errors.author && (
<p id="author-error" role="alert">
{errors.author}
</p>
)}
</div>
<button type="submit" disabled={pending}>
{pending ? '저장 중...' : '저장'}
</button>
</form>
);
}// src/components/BookForm.test.jsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import BookForm from './BookForm';
describe('BookForm', () => {
it('제목과 저자를 비운 채 제출하면 에러 메시지를 보여준다', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<BookForm onSubmit={handleSubmit} pending={false} />);
await user.click(screen.getByRole('button', { name: '저장' }));
expect(screen.getByRole('alert', { name: '제목을 입력하세요' })).toBeInTheDocument();
expect(handleSubmit).not.toHaveBeenCalled();
});
it('제목과 저자를 채우면 onSubmit이 입력값과 함께 호출된다', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<BookForm onSubmit={handleSubmit} pending={false} />);
await user.type(screen.getByLabelText('제목'), '이펙티브 자바');
await user.type(screen.getByLabelText('저자'), '조슈아 블로크');
await user.click(screen.getByRole('button', { name: '저장' }));
expect(handleSubmit).toHaveBeenCalledWith({ title: '이펙티브 자바', author: '조슈아 블로크' });
});
});3. 실행
npm run test4. 확인
✓ src/components/BookForm.test.jsx (2)
✓ BookForm > 제목과 저자를 비운 채 제출하면 에러 메시지를 보여준다
✓ BookForm > 제목과 저자를 채우면 onSubmit이 입력값과 함께 호출된다
Test Files 1 passed (1)
Tests 2 passed (2)- 터미널에 위와 같이 두 테스트가 모두 통과로 표시됩니다.
- 일부러
validateBookForm의 에러 메시지 문구를 바꿔보면 첫 번째 테스트가 실패로 바뀌는지 확인합니다(테스트가 실제로 그 문구를 검사하고 있다는 뜻입니다).
직접 해보기
- 저자만 입력하고 제목은 비운 채 제출하는 테스트를 추가해보세요. 제목 에러만 나타나고 저자 에러는 없어야 합니다.
답 보기
it('저자만 입력하면 제목 에러만 표시된다', async () => {
const user = userEvent.setup();
render(<BookForm onSubmit={vi.fn()} pending={false} />);
await user.type(screen.getByLabelText('저자'), '마틴 파울러');
await user.click(screen.getByRole('button', { name: '저장' }));
expect(screen.getByRole('alert', { name: '제목을 입력하세요' })).toBeInTheDocument();
expect(screen.queryByText('저자를 입력하세요')).not.toBeInTheDocument();
});validateBookForm함수만 따로 unit 테스트를 작성해보세요. 컴포넌트 렌더링 없이 입력값과 반환값만 확인합니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
document is not defined 오류 | vite.config.js에 environment: 'jsdom'이 없음 | test 설정에 environment: 'jsdom' 추가 |
toBeInTheDocument is not a function | jest-dom matcher가 등록되지 않음 | setupFiles에 src/test/setup.js 연결, 그 안에서 @testing-library/jest-dom/vitest import |
| 클릭 후 상태 변화가 반영되지 않음 | user.click을 await 없이 호출 | user-event의 모든 상호작용 메서드는 비동기이므로 await 필수 |
getByRole('alert')가 여러 개 찾음 | 제목·저자 에러가 동시에 존재하는데 이름 없이 조회 | { name: '...' }로 좁히거나 getAllByRole 사용 |
확인 문제
react_3 출발점: react_2 종료 시점 src/ 구조
react_3는 아래 상태에서 시작합니다. 서버 상태 라이브러리(TanStack Query)·인증·api/·store/·i18n/·mocks/는 아직 없고, react_3 1편에서 추가합니다.
bookshelf/src/
├── main.jsx
├── App.jsx // 라우터 렌더 + 레이아웃(Header, Outlet)
├── App.css // 레이아웃 골격만
├── index.css // 전역 리셋·CSS 변수
├── router.jsx // createBrowserRouter, lazy+Suspense, 에러 경계
├── data/books.seed.json
├── components/
│ ├── BookCard.jsx // compound: Cover/Title/Status/DeleteButton
│ ├── BookCard.module.css
│ ├── BookForm.jsx
│ ├── Header.jsx
│ ├── Header.module.css
│ └── Modal.jsx // createPortal + 포커스 트랩(12편)
├── context/ThemeContext.jsx
├── reducers/booksReducer.js // 정규화 { byId, allIds }
├── utils/
│ ├── booksStorage.js // loader와 공유하는 저장소 함수
│ └── validateBookForm.js
├── hooks/{useLocalStorage,useDebounce,useBooks}.js
├── pages/{BookListPage,BookDetailPage,NewBookPage}.jsx
└── test/setup.js