| name | write-test |
| description | React 단위 테스트(Vitest) 및 E2E 테스트(Playwright) 작성 컨벤션과 Mocking 가이드 |
테스트 작성 가이드
테스트 구조
src/__tests__/ ← 프론트엔드 단위 테스트 (Vitest + jsdom)
├── setup.ts ← @testing-library/jest-dom import
├── hooks/ ← 커스텀 훅 테스트 (22개)
├── lib/ ← 유틸/라이브러리 테스트 (16개)
└── components/ ← 컴포넌트 렌더링 테스트
functions/src/__tests__/ ← Cloud Functions 테스트 (별도 vitest 설정)
├── emulator.setup.ts ← Firebase Emulator 초기화
├── *.test.ts ← 순수 단위 테스트
└── *.emulator.test.ts ← Emulator 연동 테스트
e2e/ ← E2E 테스트 (Playwright)
└── *.spec.ts ← 12개 시나리오
1. 단위 테스트 (Vitest)
파일 위치 규칙
| 대상 | 위치 | 파일명 |
|---|
| 커스텀 훅 | src/__tests__/hooks/ | useXxx.test.ts |
| lib 유틸 | src/__tests__/lib/ | 모듈명.test.ts |
| 컴포넌트 | src/__tests__/components/ | ComponentName.test.tsx |
| Cloud Function | functions/src/__tests__/ | 함수명.test.ts |
⚠️ 소스 파일 옆에 .test.ts를 두지 않는다. __tests__/ 디렉터리에 통합 관리.
Hook 테스트 패턴
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
const mockShowToast = vi.fn();
vi.mock('../../hooks/useToast', () => ({
useToast: () => ({ showToast: mockShowToast }),
}));
import useMyHook from '../../hooks/useMyHook';
describe('useMyHook', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('정상 케이스를 설명한다', async () => {
const { result } = renderHook(() => useMyHook());
await act(async () => {
await result.current.doSomething();
});
expect(result.current.state).toBe('expected');
});
});
Firestore 및 Auth Mock 패턴
Firebase 모듈은 항상 mock 처리한다:
vi.mock('../../lib/firebase', () => ({
db: {},
auth: { currentUser: { uid: 'test-uid' } },
}));
vi.mock('firebase/firestore', () => ({
collection: vi.fn(),
doc: vi.fn(),
getDocs: vi.fn(),
query: vi.fn(),
where: vi.fn(),
orderBy: vi.fn(),
Timestamp: {
now: () => ({ toDate: () => new Date() }),
fromDate: (d: Date) => ({ toDate: () => d }),
},
}));
상태 변경 부작용과 act(...) 래핑
- 컴포넌트 렌더링 테스트나 커스텀 훅 내에서 상태 변경(state update)이 일어나는 모든 이벤트(클릭, 타이핑)와 비동기 결과 처리는 반드시
@testing-library/react의 act(...) 로 감싸야 한다.
- 그렇지 않을 경우
Warning: An update to X inside a test was not wrapped in act(...) 경고가 발생하며, 비동기 상태의 단언(assertion)이 실패할 수 있다.
⚠️ Mock 안티패턴 (반드시 회피)
함정 1: 참조 불안정 → 무한 렌더 루프
Hook의 useEffect 의존성에 Mock 반환값이 포함되면, 매 렌더마다 새 객체가 생성되어 무한 루프가 발생한다.
vi.mock('../../hooks/useToast', () => ({
useToast: () => ({ showToast: vi.fn() }),
}));
const mockShowToast = vi.fn();
vi.mock('../../hooks/useToast', () => ({
useToast: () => ({ showToast: mockShowToast }),
}));
증상: 테스트가 5000ms 타임아웃으로 실패. act() 경고가 대량 출력.
함정 2: 미mock 비동기 함수 → 타임아웃
Hook 내부의 useEffect가 호출하는 비동기 함수가 mock되지 않으면, Promise가 영원히 미결(pending) 상태로 남는다.
vi.mock('../../lib/firestore', () => ({
getVehicles: vi.fn(),
getLastVehicleEndKm: vi.fn(),
}));
vi.mock('../../lib/firestore', () => ({
getVehicles: vi.fn(),
getLastVehicleEndKm: vi.fn(),
getVehicleEndKmBefore: vi.fn().mockResolvedValue(null),
}));
증상: 특정 테스트만 타임아웃. 로직이 단순해 보이는데 왜 느린지 이해가 안 됨.
함정 3: exhaustive-deps + Mock 충돌
useAuth()의 user 객체처럼 넓은 객체를 의존성 배열에 넣으면, 메타데이터 변경(displayName 등)만으로도 effect가 재실행된다.
useEffect(() => {
fetchData(user.orgId);
}, [user]);
useEffect(() => {
fetchData(user.orgId);
}, [user.orgId]);
부득이하게 넓은 객체를 써야 하면 // eslint-disable-next-line react-hooks/exhaustive-deps 주석을 추가하되, stale closure 위험이 없는지 반드시 검증 후 사용한다.
테스트 이름 작성 규칙
- 한글로 작성 (
it('성공 시 결과를 반환한다'))
- "~한다" 형식으로 동작을 서술
- 에러 케이스:
'XXX 에러 시 토스트를 표시한다'
2. Cloud Functions 테스트
순수 단위 테스트 (*.test.ts)
Firebase Admin을 mock하여 네트워크 없이 실행:
vi.mock('firebase-admin/firestore', () => ({
getFirestore: () => mockDb,
}));
Emulator 연동 테스트 (*.emulator.test.ts)
실제 Emulator에 데이터를 넣고 검증:
import { setup, teardown } from './emulator.setup';
beforeAll(async () => { await setup(); });
afterAll(async () => { await teardown(); });
Emulator 테스트는 CI에서 실행 시간이 길므로, 핵심 비즈니스 로직에만 사용.
3. E2E 테스트 (Playwright)
파일 위치
e2e/ 디렉터리에 기능명.spec.ts로 생성.
작성 원칙
- Happy Path 우선: 핵심 사용자 흐름을 먼저 확보
- 인증 우회: Firebase Emulator의 test token 사용
- DOM 안정성:
page.waitForSelector 대신 expect(locator).toBeVisible() 사용
- 불안정 테스트: 네트워크 의존 테스트는
test.fixme()로 마킹
import { test, expect } from '@playwright/test';
test('랜딩 페이지가 정상 로드된다', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toBeVisible();
await expect(page).toHaveTitle(/차량운행일지/);
});
4. 언제 테스트를 작성하는가
agents.md §5.3 판단 가이드 참고
| 상황 | 테스트 여부 |
|---|
| 비즈니스 로직이 있는 Hook | ✅ 필수 |
| 유틸/헬퍼 함수 | ✅ 필수 |
| Cloud Function (onCall, trigger) | ✅ 필수 |
| 순수 UI 컴포넌트 (표시만) | ❌ 불필요 |
| 라우팅/레이아웃 변경 | ❌ 불필요 |
5. 실행 명령어
npm run test
npm run test:e2e
전체 테스트 스위트 실행은 /test 워크플로우를 사용한다.