테스트 가이드
너홀로프로는 3-계층 테스트 전략을 씁니다.
| 계층 | 도구 | 대상 | 실행 |
|---|---|---|---|
| 단위 (Unit) | Vitest | 순수 로직, Server Action, helper | pnpm test |
| 컴포넌트 (Story) | Storybook | UI 상태·variant·접근성 | pnpm storybook |
| 통합 (E2E) | Playwright | 사용자 시나리오 전체 | pnpm e2e (Firebase 에뮬레이터 필요) |
로직은 순수 함수로 분리해 mock 없이 테스트. UI 는 Storybook 에서 상태별로 시각 확인, 사용자 경로는 E2E 로 회귀 방지. 세 계층은 서로 대체가 아니라 역할 분담.
1. Vitest — 단위 테스트
위치 컨벤션
테스트는 테스트 대상과 같은 디렉토리 의 __tests__/ 에 둡니다.
app/(workspace)/cases/_actions/
├── case-create-actions.ts
└── __tests__/
└── case-create-actions.test.ts
app/(workspace)/cases/_lib/
├── briefing-context.ts
└── __tests__/
└── briefing-context.test.ts
순수 로직은 가능한 한 _lib/ 로 분리해서 mock 없이 테스트 합니다. Firebase 나 Server Action 의존이 있는 코드는 _actions/ 쪽 테스트에서 mock 활용.
실행
pnpm test # 전체 (web + docs)
pnpm --filter=@neohollo/web test # web 만
pnpm --filter=@neohollo/web test -- <패턴> # 특정 파일
pnpm --filter=@neohollo/web test -- --watch # watch 모드
vitest.config.ts 는 **/__tests__/**/*.test.{ts,tsx} 패턴을 수집합니다.
Firebase 모킹 패턴 (필수)
"use server" 파일을 테스트할 때는 Firebase Admin 초기화를 반드시 차단해야 합니다. 누락 시 실제 Firestore 에 붙으려 시도합니다.
import { describe, it, expect, vi, beforeEach } from "vitest";
// ① 호이스트된 mock 변수 — vi.mock 안에서 참조 가능
const { mockRequireStaffSession, mockGetServerSession } = vi.hoisted(() => ({
mockRequireStaffSession: vi.fn(),
mockGetServerSession: vi.fn(),
}));
// ② Firebase admin 초기화 차단
vi.mock("@/lib/firebase/admin", () => ({
adminDb: {
collection: vi.fn(),
doc: vi.fn(),
batch: vi.fn(),
},
}));
// ③ 세션 / 경로 유틸 모킹
vi.mock("@/lib/firebase/auth", () => ({
requireStaffSession: mockRequireStaffSession,
getServerSession: mockGetServerSession,
}));
vi.mock("@/lib/firebase/paths", () => ({
casesRef: vi.fn(),
caseRef: vi.fn(),
}));
// ④ 테스트 대상은 **절대 경로**로 직접 import
import { createCaseAction } from "@/app/(workspace)/cases/_actions/case-create-actions";
describe("createCaseAction", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("세션 없으면 인증 에러", async () => {
mockRequireStaffSession.mockRejectedValue(new Error("NO_SESSION"));
const result = await createCaseAction(new FormData());
expect(result.success).toBe(false);
});
});
Server Action 테스트 무결성 테스트
apps/web/__tests__/server-action-integrity.test.ts 가 모든 "use server" 파일이 async function 만 export 하는지, 배럴·상대경로 import 를 쓰지 않는지 자동 검증합니다. 새 Server Action 파일을 만들 때 별도 테스트 없이도 이 규칙 위반이 자동으로 걸립니다.
@neohollo/business-logic 직접 단위 테스트 (ADR 0028)
ADR 0028 로 추출된 도메인 (cases · clients · documents · evidence · execution · hearings · members · messages · portal · recoveries · refinement-feedback · snippets · tenant · case-comments) 은 wrapper 우회 직접 테스트 가능합니다. wrapper 가 아닌 mutation 자체 (Zod 검증 · Firestore write · 자동 상태 전이 등) 의 회귀 가드.
테스트 파일: apps/web/__tests__/business-logic-*.test.ts. 헬퍼: apps/web/__tests__/_helpers/fake-firestore.ts — emulator 없이 in-memory Firestore 시뮬.
import { describe, it, expect, vi } from "vitest";
vi.mock("firebase-admin/firestore", () => ({
FieldValue: {
serverTimestamp: () => "SERVER_TIMESTAMP",
delete: () => "FV_DELETE",
},
}));
import { addCaseComment } from "@neohollo/business-logic/case-comments";
import { createFakeDb } from "./_helpers/fake-firestore";
it("정상 추가 — 작성자 stamp + trim", async () => {
const fake = createFakeDb();
const out = await addCaseComment(
{
db: fake.db,
tenantId: "t-1",
actorUid: "u-1",
actorRole: "owner",
},
{ caseId: "c-1", content: " 본문 ", authorName: "Alice" },
);
expect(out.commentId).toMatch(/^auto-/);
const set = fake.writes.sets[0];
expect(set.data.content).toBe("본문"); // trim 적용
expect(set.data.authorRole).toBe("owner");
});
fakeDb 가 지원하는 API: collection · doc · get · set · update · delete · add · where('==') · count().get() · orderBy/limit (no-op) · runTransaction · batch().commit. == operator 만 지원 — 그 외 op 는 명시 throw 로 패키지 변경 시 테스트가 즉시 실패. 배포 전 회귀 가드.
dryRun ctx 옵션 검증도 같이 — dryRun: true 면 Firestore write 가 0 회여야 하고 결과 객체는 정상 반환되어야 한다는 invariant 가 모든 mutation 의 표준 테스트 항목.
커버리지 임계값
vitest.config.ts 에 디렉토리별 임계값이 선언되어 있습니다 (branches/functions/lines/statements 60% 이상):
lib/vertex-search/ · lib/rate-limit/ · lib/security/ · lib/firestore/
lib/activity/ · lib/notifications/ · lib/briefing/
해당 영역 수정 시 커버리지 떨어뜨리면 CI 실패. 새 lib 모듈은 비슷한 임계값으로 추가 권장.
pnpm --filter=@neohollo/web test -- --coverage
2. Storybook — 컴포넌트 상태
위치
UI 컴포넌트 스토리는 apps/web/stories/ 에 모여 있습니다 (약 144 stories). 파일명은 *.stories.tsx.
pnpm storybook # 포트 6006
pnpm build-storybook # 정적 빌드
작성 기준
- 모든 shadcn/ui 래퍼 컴포넌트는 스토리 1개 이상 — variant · size · disabled 상태 커버
- AI 결과 컴포넌트는 상태별 스토리 필수 — 로딩 · 성공 · 폴백 · 빈값 · readOnly 한도초과 (AI 파이프라인 (A) 범주)
- 다크 모드:
@storybook/addon-themes로 자동 전환 — 별도 스토리 불필요
.storybook/
main.ts— addon 설정preview.tsx— 글로벌 데코레이터 (테마 · i18n · mock provider)mocks/— next/navigation · next-intl · Firebase 클라이언트 모킹
3. Playwright — E2E
전제
Firebase 에뮬레이터가 localhost:5002 에 떠 있어야 합니다 (playwright.config.ts baseURL). 로컬 개발 서버(3000)가 아닌 에뮬레이터 포트입니다.
firebase emulators:start # 별도 터미널에서 먼저
pnpm e2e # 웹 빌드 → Playwright 실행
pnpm e2e 는 루트에서 정의된 대로 web 빌드 후 apps/web/e2e 디렉토리를 실행합니다. 단, Playwright 설정 자체는 루트 playwright.config.ts 에 있고 testDir: ./e2e 를 가리킵니다.
스펙 구성 (e2e/ 17개)
| 번호 | 스펙 | 커버리지 |
|---|---|---|
| 01 | auth | 로그인·회원가입·세션 |
| 02 | dashboard | 브리핑 · 퀵 액션 |
| 03 | case-wizard | 사건 생성 마법사 |
| 04 | screens | 주요 화면 스모크 |
| 05 | theme | 다크 모드 · 색상 토큰 |
| 06 | case-detail | 사건 상세 · 기일 · 증거 |
| 07 | doc-generate | AI 서류 생성 |
| 08 | clients | 의뢰인 목록 · 대화 |
| 09 | settings | 설정 · 플랜 |
| 10 | staff-invite | 팀원 초대 |
| 11 | portal | 의뢰인 포털 (4자리 접속 코드) |
| 12~17 | activity / calendar / revenue / filter / doc-edit / error | 나머지 주요 경로 |
fixtures/— seed 데이터helpers/— 로그인·사건 생성 등 공통 헬퍼global-setup.ts— 에뮬레이터 초기화
실행 옵션
pnpm e2e # 전체
pnpm --filter=@neohollo/web e2e -- 11-portal # 포털만
pnpm --filter=@neohollo/web e2e -- --ui # UI 모드 (디버깅)
pnpm --filter=@neohollo/web e2e -- --debug # 스텝 디버그
실패 스크린샷은 e2e/results/ 에 저장됩니다 (.gitignore 됨). retry 1회, 순차 실행(fullyParallel: false) — 테스트 간 상태 의존 때문.
CSP · 보안 헤더 회귀
apps/web/__tests__/next-config-headers.test.ts 가 CSP 헤더를 검증합니다. 새 외부 도메인을 추가하면 이 테스트가 실패하므로, apps/web/lib/security/csp-headers.ts 의 getCspDirectives() 와 테스트를 동시에 업데이트하세요.
4. 언제 어떤 테스트를 쓸까
| 상황 | 테스트 |
|---|---|
순수 함수 (buildFallbackText, calcLoanAmounts) | Vitest 단위 (mock 없음) |
| Server Action | Vitest 단위 (Firebase mock) |
| UI 컴포넌트의 상태 variant | Storybook |
| 다크 모드 · 접근성 | Storybook addon |
| 로그인 → 사건 생성 → 기일 등록 → 포털 확인 | Playwright E2E |
| CSP 헤더 · Server Action 무결성 | 전용 __tests__/*-integrity.test.ts (이미 존재) |
5. 테스트 작성 체크리스트
PR 올리기 전:
- 새 Server Action → 동일 도메인
__tests__/에 테스트 추가 (CLAUDE.md: 200줄 이하 권장, 초과 시 분리) - 새 lib 순수 함수 → mock 없는 단위 테스트
- UI 컴포넌트 신규·수정 → Storybook 스토리 추가/갱신
- 사용자 플로우에 영향 → E2E 스펙 동기화 (
e2e/) -
pnpm type-check && pnpm lint && pnpm test로컬 통과 - AI 기능 수정 시 폴백 케이스 테스트 필수 (AI 파이프라인 (A) 범주는
buildFallbackText(ctx)순수 함수 테스트로 충분)
6. 자주 발생하는 문제
- "Firebase Admin already initialized" —
vi.mock("@/lib/firebase/admin")누락. 또는 다른 테스트가 초기화한 상태가 남음 →beforeEach에서vi.clearAllMocks() - Vitest 는 통과하는데
next build실패 —pnpm type-check는 tsc 기준,next build보다 엄격. 로컬type-check통과 확인 - E2E 타임아웃 — 에뮬레이터 미기동,
.firebaserc프로젝트 매핑 확인 - Storybook 에서 Firebase 에러 —
.storybook/mocks/에 해당 모듈 mock 추가