Skip to content

테스팅 가이드


테스트 우선순위

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 storybook

Coverage 확인

bash
npm run test:coverage

# coverage/index.html 열기
open coverage/index.html

테스트 작성 체크리스트

  • [ ] 유틸 함수는 테스트 작성 (필수)
  • [ ] 복잡한 로직이 있는 훅은 테스트 작성
  • [ ] Edge case 처리 (null, undefined, 빈 배열 등)
  • [ ] 에러 케이스 처리
  • [ ] 비동기 작업은 waitFor 또는 act 사용
  • [ ] Mock은 afterEach에서 정리
  • [ ] 테스트명은 한글로 명확하게
  • [ ] 하나의 테스트는 하나의 기능만 검증