테스팅 가이드
테스트 우선순위
1순위: Pure Functions (필수)
유틸리티 함수는 반드시 테스트 작성
typescript
// utils/datetime.test.ts
import { describe, expect, it } from 'vitest';
import { formatDatetime, parseDate } from './datetime';
describe('datetime utils', () => {
describe('formatDatetime', () => {
it('날짜를 YYYY-MM-DD HH:mm 형식으로 변환한다', () => {
const date = new Date('2025-01-20T14:30:00');
expect(formatDatetime(date)).toBe('2025-01-20 14:30');
});
it('null 값을 처리한다', () => {
expect(formatDatetime(null)).toBe('-');
});
});
describe('parseDate', () => {
it('문자열을 Date 객체로 변환한다', () => {
const result = parseDate('2025-01-20');
expect(result).toBeInstanceOf(Date);
expect(result.getFullYear()).toBe(2025);
});
});
});2순위: Custom Hooks (복잡한 로직만)
typescript
// hooks/useForm.test.ts
import { renderHook, act } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useForm } from './useForm';
describe('useForm', () => {
it('초기값을 설정한다', () => {
const { result } = renderHook(() => useForm({ initialValues: { name: '', email: '' } }));
expect(result.current.values.name).toBe('');
expect(result.current.values.email).toBe('');
});
it('값을 변경한다', () => {
const { result } = renderHook(() => useForm({ initialValues: { name: '' } }));
act(() => {
result.current.handleChange('name', 'John');
});
expect(result.current.values.name).toBe('John');
});
it('validation을 실행한다', () => {
const { result } = renderHook(() =>
useForm({
initialValues: { email: '' },
validation: {
email: (value) => (value ? null : '이메일을 입력하세요'),
},
}),
);
act(() => {
result.current.handleBlur('email');
});
expect(result.current.errors.email).toBe('이메일을 입력하세요');
});
it('formState를 참조하는 validation을 실행한다', () => {
const { result } = renderHook(() =>
useForm({
initialValues: { password: '', confirmPassword: '' },
validation: {
confirmPassword: (value, formState) => {
if (value !== formState.password) {
return '비밀번호가 일치하지 않습니다';
}
return null;
},
},
}),
);
act(() => {
result.current.handleChange('password', '1234');
result.current.handleChange('confirmPassword', '5678');
result.current.handleBlur('confirmPassword');
});
expect(result.current.errors.confirmPassword).toBe('비밀번호가 일치하지 않습니다');
});
});3순위: 컴포넌트 (선택)
단순 렌더링 테스트는 생략 가능, 복잡한 로직만 테스트
typescript
// components/Switch/Switch.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import Switch from './Switch';
describe('Switch', () => {
it('체크 상태를 토글한다', () => {
const onChange = vi.fn();
render(<Switch checked={false} onChange={onChange} />);
const switchElement = screen.getByRole('switch');
fireEvent.click(switchElement);
expect(onChange).toHaveBeenCalledWith(true);
});
it('disabled 상태에서는 토글되지 않는다', () => {
const onChange = vi.fn();
render(<Switch checked={false} onChange={onChange} disabled />);
const switchElement = screen.getByRole('switch');
fireEvent.click(switchElement);
expect(onChange).not.toHaveBeenCalled();
});
});테스트 파일 구조
파일 위치
ComponentName/
├── ComponentName.tsx
├── ComponentName.css.ts
├── ComponentName.test.tsx # 컴포넌트 테스트
└── ComponentName.stories.tsx # Storybook
utils/
├── datetime.ts
└── datetime.test.ts # 유틸 테스트
hooks/
├── useForm.ts
└── useForm.test.ts # 훅 테스트API 호출 테스트
Mock 사용
typescript
// entities/user/api/user.api.test.ts
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { getCurrentUser } from './user.api';
import { api } from '@shared/api/base';
vi.mock('@shared/api/base');
describe('user.api', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getCurrentUser', () => {
it('현재 사용자 정보를 가져온다', async () => {
const mockUser = {
id: '1',
name: 'John',
email: 'john@example.com',
};
vi.mocked(api.get).mockResolvedValue({ data: mockUser });
const result = await getCurrentUser();
expect(api.get).toHaveBeenCalledWith('/api/users/me');
expect(result).toEqual(mockUser);
});
it('에러를 처리한다', async () => {
vi.mocked(api.get).mockRejectedValue(new Error('Network Error'));
await expect(getCurrentUser()).rejects.toThrow('Network Error');
});
});
});React Query 테스트
QueryClient 래퍼
typescript
// test-utils.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render } from '@testing-library/react';
export function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
}
export function renderWithQueryClient(ui: React.ReactElement) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>
);
}훅 테스트
typescript
// features/user/model/useCurrentUser.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { useCurrentUser } from './useCurrentUser';
import { getCurrentUser } from '@entities/user';
import { createTestQueryClient } from '@/test-utils';
vi.mock('@entities/user');
describe('useCurrentUser', () => {
it('사용자 정보를 가져온다', async () => {
const mockUser = { id: '1', name: 'John', email: 'john@example.com' };
vi.mocked(getCurrentUser).mockResolvedValue(mockUser);
const { result } = renderHook(() => useCurrentUser(), {
wrapper: ({ children }) => (
<QueryClientProvider client={createTestQueryClient()}>
{children}
</QueryClientProvider>
),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockUser);
});
});비동기 테스트
waitFor 사용
typescript
import { waitFor } from '@testing-library/react';
it('비동기 작업을 완료한다', async () => {
const { result } = renderHook(() => useAsyncHook());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.data).toBeDefined();
});act 사용
typescript
import { act } from '@testing-library/react';
it('상태를 업데이트한다', async () => {
const { result } = renderHook(() => useCustomHook());
await act(async () => {
await result.current.fetchData();
});
expect(result.current.data).toBeDefined();
});Mock 패턴
함수 Mock
typescript
import { vi } from 'vitest';
const mockFunction = vi.fn();
mockFunction.mockReturnValue('value');
mockFunction.mockResolvedValue('async value');
mockFunction.mockRejectedValue(new Error('error'));
expect(mockFunction).toHaveBeenCalled();
expect(mockFunction).toHaveBeenCalledWith('arg');
expect(mockFunction).toHaveBeenCalledTimes(2);모듈 Mock
typescript
// 전체 모듈 mock
vi.mock('@shared/api/base');
// 특정 함수만 mock
vi.mock('@shared/utils/datetime', () => ({
formatDatetime: vi.fn(() => '2025-01-20'),
parseDate: vi.fn(),
}));Timer Mock
typescript
import { vi } from 'vitest';
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('debounce가 동작한다', () => {
const fn = vi.fn();
const debounced = debounce(fn, 300);
debounced();
debounced();
debounced();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
});
});Storybook
Story 작성
typescript
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import Button from './Button';
const meta = {
title: 'Atoms/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'outlined'],
description: '버튼 스타일 변형',
},
size: {
control: 'select',
options: ['small', 'medium', 'large'],
description: '버튼 크기',
},
disabled: {
control: 'boolean',
description: '비활성화 상태',
},
},
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: {
variant: 'primary',
children: 'Button',
},
};
export const Secondary: Story = {
args: {
variant: 'secondary',
children: 'Button',
},
};
export const AllSizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<Button size="small">Small</Button>
<Button size="medium">Medium</Button>
<Button size="large">Large</Button>
</div>
),
};테스트 실행
명령어
bash
# 전체 테스트
npm run test
# Watch mode
npm run test:watch
# Coverage
npm run test:coverage
# UI mode
npm run test:ui
# Storybook
npm run storybookCoverage 확인
bash
npm run test:coverage
# coverage/index.html 열기
open coverage/index.html테스트 작성 체크리스트
- [ ] 유틸 함수는 테스트 작성 (필수)
- [ ] 복잡한 로직이 있는 훅은 테스트 작성
- [ ] Edge case 처리 (null, undefined, 빈 배열 등)
- [ ] 에러 케이스 처리
- [ ] 비동기 작업은 waitFor 또는 act 사용
- [ ] Mock은 afterEach에서 정리
- [ ] 테스트명은 한글로 명확하게
- [ ] 하나의 테스트는 하나의 기능만 검증