이번 편의 결과물: useBooks, 책 등록 zod 스키마, 필터용 Zustand 스토어에 대한 단위 테스트가 추가되어 통과합니다. · 다루는 개념: 커스텀 훅 테스트(renderHook), 폼 검증 로직 테스트, Zustand 스토어 테스트, RTL 쿼리·유저 이벤트
이 편에서 만드는 파일
bookshelf/src/
├── test/
│ ├── useBooks.test.jsx (~) 서버 상태 훅 테스트로 재작성
│ ├── bookSchema.test.js (+) zod 스키마 단위 테스트
│ ├── useUiStore.test.js (+) Zustand 스토어 단위 테스트
│ └── BookForm.test.jsx (+) 폼 컴포넌트 RTL 테스트개념 정리
03편에서 정한 단위 테스트의 경계
03편에서 테스트 피라미드의 각 층이 무엇을 보는지 정했습니다. 단위 테스트는 네트워크를 타지 않는 순수 로직만 봅니다. useBooks가 내부에서 TanStack Query를 쓰더라도, 이 편에서는 실제 요청을 MSW로 가로채지 않고 queryFn을 직접 주입하거나 짧은 목 함수로 대체합니다. 서버 응답과 함께 동작하는지는 15편(통합 테스트)의 몫입니다.
| 대상 | 도구 | 확인하는 것 |
|---|---|---|
| 커스텀 훅 | renderHook(@testing-library/react) | 반환값과 상태 전이가 기대한 대로 바뀌는가 |
| zod 스키마 | schema.safeParse() | 올바른 입력은 통과, 잘못된 입력은 각 필드에 에러가 붙는가 |
| Zustand 스토어 | 스토어를 직접 import해 액션 호출 | 액션 호출 후 상태가 기대한 값으로 바뀌는가 |
| 폼 컴포넌트 | RTL render + userEvent | 사용자가 타이핑·클릭했을 때 화면에 보이는 결과 |
renderHook은 훅을 컴포넌트 없이 실행하는 도구다
훅은 컴포넌트 안에서만 호출할 수 있습니다. renderHook(() => useBooks())은 보이지 않는 테스트 전용 컴포넌트 안에서 훅을 실행하고, result.current로 반환값을 확인하게 해줍니다. useBooks가 TanStack Query를 쓰므로, wrapper 옵션으로 QueryClientProvider를 감싸줘야 합니다.
Zustand 스토어는 테스트마다 초기화해야 한다
Zustand 스토어는 모듈 스코프에 상태를 유지합니다. 한 테스트에서 값을 바꾸면 다음 테스트에도 그대로 남아 있어 테스트 간 간섭이 생깁니다. beforeEach에서 스토어를 초기 상태로 되돌리는 리셋 함수를 마련합니다.
실습
1. Zustand 스토어 확인
09·11편이 만든 store/useUiStore.js는 statusFilter·sortBy·sortOrder·searchQuery·page·isSidebarOpen과 resetFilters 액션을 갖고 있습니다. 이 편은 그 구조를 그대로 테스트합니다. 새로 만들 필요는 없습니다.
2. useBooks 훅 테스트 작성
// bookshelf/src/test/useBooks.test.jsx
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useBooks } from '../hooks/useBooks.js';
import * as booksApi from '../api/books.js';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Wrapper({ children }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
describe('useBooks', () => {
it('조회에 성공하면 books 배열을 반환한다', async () => {
vi.spyOn(booksApi, 'getBooks').mockResolvedValue([
{ id: 1, title: '클린 코드', status: 'done' },
]);
const { result } = renderHook(() => useBooks(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toHaveLength(1);
expect(result.current.data[0].title).toBe('클린 코드');
});
it('조회에 실패하면 isError가 true다', async () => {
vi.spyOn(booksApi, 'getBooks').mockRejectedValue(new Error('요청 실패'));
const { result } = renderHook(() => useBooks(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isError).toBe(true));
});
});3. zod 스키마 테스트 작성
// bookshelf/src/test/bookSchema.test.js
import { describe, it, expect } from 'vitest';
import { bookSchema } from '../schemas/bookSchema.js';
describe('bookSchema', () => {
it('필수 필드를 모두 채우면 통과한다', () => {
const result = bookSchema.safeParse({
title: '리팩터링 2판',
author: '마틴 파울러',
status: 'reading',
pages: 550,
});
expect(result.success).toBe(true);
});
it('title이 비어 있으면 title 필드에 에러가 붙는다', () => {
const result = bookSchema.safeParse({
title: '',
author: '마틴 파울러',
status: 'reading',
pages: 550,
});
expect(result.success).toBe(false);
expect(result.error.issues.some((issue) => issue.path[0] === 'title')).toBe(true);
});
it('pages가 음수면 실패한다', () => {
const result = bookSchema.safeParse({
title: '리팩터링 2판',
author: '마틴 파울러',
status: 'reading',
pages: -10,
});
expect(result.success).toBe(false);
});
});4. Zustand 스토어 테스트 작성
// bookshelf/src/test/useUiStore.test.js
import { describe, it, expect, beforeEach } from 'vitest';
import { useUiStore } from '../store/useUiStore.js';
beforeEach(() => {
useUiStore.getState().resetFilters();
});
describe('useUiStore', () => {
it('setStatusFilter를 호출하면 필터가 바뀌고 페이지가 1로 리셋된다', () => {
useUiStore.getState().setPage(3);
useUiStore.getState().setStatusFilter('reading');
expect(useUiStore.getState().statusFilter).toBe('reading');
expect(useUiStore.getState().page).toBe(1);
});
it('같은 컬럼으로 setSort를 두 번 호출하면 방향이 뒤집힌다', () => {
useUiStore.getState().setSort('title');
expect(useUiStore.getState().sortOrder).toBe('desc');
useUiStore.getState().setSort('title');
expect(useUiStore.getState().sortOrder).toBe('asc');
});
it('resetFilters를 호출하면 초기값으로 돌아간다', () => {
useUiStore.getState().setStatusFilter('done');
useUiStore.getState().setSort('rating');
useUiStore.getState().resetFilters();
expect(useUiStore.getState().statusFilter).toBe('all');
expect(useUiStore.getState().sortBy).toBe('title');
});
});5. 폼 컴포넌트 RTL 테스트 작성
// bookshelf/src/test/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 '../components/BookForm.jsx';
describe('BookForm', () => {
it('제목을 비운 채 제출하면 에러 메시지가 보인다', async () => {
const user = userEvent.setup();
const handleSubmitBook = vi.fn();
render(<BookForm onSubmitBook={handleSubmitBook} submitLabel="등록" />);
await user.click(screen.getByRole('button', { name: '등록' }));
expect(await screen.findByText('제목을 입력하세요')).toBeInTheDocument();
expect(handleSubmitBook).not.toHaveBeenCalled();
});
it('필수 값을 채우면 onSubmitBook이 입력값과 함께 호출된다', async () => {
const user = userEvent.setup();
const handleSubmitBook = vi.fn();
render(<BookForm onSubmitBook={handleSubmitBook} submitLabel="등록" />);
await user.type(screen.getByLabelText('제목'), '함께 자라기');
await user.type(screen.getByLabelText('저자'), '김창준');
await user.type(screen.getByLabelText('쪽수'), '224');
await user.click(screen.getByRole('button', { name: '등록' }));
expect(handleSubmitBook).toHaveBeenCalledWith(
expect.objectContaining({ title: '함께 자라기', author: '김창준', pages: 224 }),
expect.anything(),
);
});
});6. 실행
npx vitest run7. 확인
✓ src/test/useBooks.test.jsx (2)
✓ src/test/bookSchema.test.js (3)
✓ src/test/useUiStore.test.js (2)
✓ src/test/BookForm.test.jsx (2)
Test Files 4 passed (4)
Tests 9 passed (9)직접 해보기
setSort(‘rating’) 호출 전후로 page가 1로 리셋되는지 확인하는 테스트를 추가해 보세요.
useUiStore.getState().setPage(3)으로 먼저 3페이지로 옮긴 뒤 setSort('rating')을 호출하고, page가 1로 돌아왔는지 expect합니다. 11편에서 setSort가 page: 1을 함께 반환하도록 설계했으므로, 이 테스트는 그 설계를 그대로 검증합니다.
자주 하는 실수
| 증상 | 원인 | 고치는 법 |
|---|---|---|
renderHook 호출 시 “queryClient를 찾을 수 없다” 에러 | QueryClientProvider 없이 훅을 실행 | wrapper 옵션으로 Provider를 감싼 컴포넌트 전달 |
| Zustand 테스트가 실행 순서에 따라 실패했다 성공했다 한다 | 스토어 상태가 테스트 간에 공유됨 | beforeEach에서 resetFilters() 같은 리셋 액션 호출 |
getByText가 여러 개 매치돼 에러난다 | 같은 문구가 화면에 두 번 이상 있음 | getByRole이나 getByLabelText로 더 구체적인 쿼리 사용 |
| 비동기 상태 확인 전에 단언이 먼저 실행돼 실패한다 | waitFor 없이 바로 expect 호출 | 상태 전이를 기다리는 waitFor나 findBy* 쿼리 사용 |