Skip to Content
WebReactReact 실무14. Vitest + RTL로 훅·컴포넌트 단위 테스트

이번 편의 결과물: 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.jsstatusFilter·sortBy·sortOrder·searchQuery·page·isSidebarOpenresetFilters 액션을 갖고 있습니다. 이 편은 그 구조를 그대로 테스트합니다. 새로 만들 필요는 없습니다.

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 run

7. 확인

✓ 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')을 호출하고, page1로 돌아왔는지 expect합니다. 11편에서 setSortpage: 1을 함께 반환하도록 설계했으므로, 이 테스트는 그 설계를 그대로 검증합니다.

자주 하는 실수

증상원인고치는 법
renderHook 호출 시 “queryClient를 찾을 수 없다” 에러QueryClientProvider 없이 훅을 실행wrapper 옵션으로 Provider를 감싼 컴포넌트 전달
Zustand 테스트가 실행 순서에 따라 실패했다 성공했다 한다스토어 상태가 테스트 간에 공유됨beforeEach에서 resetFilters() 같은 리셋 액션 호출
getByText가 여러 개 매치돼 에러난다같은 문구가 화면에 두 번 이상 있음getByRole이나 getByLabelText로 더 구체적인 쿼리 사용
비동기 상태 확인 전에 단언이 먼저 실행돼 실패한다waitFor 없이 바로 expect 호출상태 전이를 기다리는 waitForfindBy* 쿼리 사용

확인 문제

문제 14지선다
renderHook에서 wrapper 옵션이 필요한 경우는 언제입니까
문제 24지선다
zod 스키마 단위 테스트에서 주로 확인하는 것은 무엇입니까
문제 34지선다
Zustand 스토어 테스트에서 beforeEach마다 초기화가 필요한 이유는 무엇입니까
문제 44지선다
BookForm 테스트에서 userEvent를 쓰는 이유는 무엇입니까
문제 54지선다
이 편의 단위 테스트가 다루지 않는 것은 무엇입니까

참고 자료

Last updated on