한 기능을 수직으로 덮는 테스트 피라미드: Weekly Report 기능의 50+ 테스트 사례 연구
주간 리포트 하나를 출시하는 데 유닛 41개 + E2E 10개 = 51개 테스트가 필요했다. 같은 기능을 왜 여러 층위에서 반복 검증하는가? 피라미드 각 층의 역할과 '어떤 테스트를 어디에 둘지'의 구체적 선택.
1. 문제 상황
1.1 "이 테스트가 너무 많은 거 아닌가요?"
주간 리포트 기능의 PR을 보면서 팀원이 물었습니다.
"유닛 테스트, 컴포넌트 테스트, API 테스트, E2E 테스트까지 같은 기능을 네 번 검증하고 있는데 중복 아니에요?"
합리적인 질문입니다. 같은 "리포트 자동 생성" 플로우가:
project-status-log.test.ts—getActiveProjectIdsInWeek유닛 테스트weekly-report.test.ts— API 레벨 통합 테스트 (Prisma mock 사용)weekly-report-table.test.tsx— 테이블 렌더링 컴포넌트 테스트e2e/saas/weekly-report.spec.ts— Playwright 브라우저 E2E
네 곳에서 각자 다른 각도로 검증됩니다. 중복처럼 보이지만, 각 층위는 다른 버그를 잡습니다.
1.2 전체 테스트 수량
PR #152에 포함된 테스트:
| 파일 | 수 | 종류 |
|---|---|---|
project-status-log.test.ts |
18 | 유닛 (라이브러리 함수) |
weekly-report.test.ts |
12 | API 통합 (request → response) |
report-entries.test.ts |
11 | API 통합 (PATCH/POST/DELETE) |
authorize.test.ts |
2 | 유닛 (RBAC helper 확장) |
projects.test.ts |
6 | API 통합 (ProjectStatusLog 자동 기록) |
week-utils.test.ts |
22 | 유닛 (타임존 변환) |
weekly-report-table.test.tsx 등 |
N | 컴포넌트 (React Testing Library) |
e2e/saas/weekly-report.spec.ts |
10 | E2E (Playwright) |
총 ~80+ 개. "한 기능 하나"를 검증하는 테스트로 많아 보이지만, 한 개씩 뜯어보면 각자 고유한 의무가 있습니다.
2. 테스트 피라미드 복습
2.1 고전적 피라미드
Martin Fowler가 유명하게 만든 "test pyramid":
┌──────────┐
│ E2E │ ← 느림, 값진 신뢰, 적게
├──────────┤
│ 통합 │ ← 중간
├──────────┤
│ 유닛 │ ← 빠름, 많이
└──────────┘
- 유닛: 함수 단위 로직 검증, ms 단위 실행, 수백~수천 개
- 통합: 여러 함수/DB/외부 시스템 경계 검증, 초 단위, 수십~수백 개
- E2E: 전체 시스템 플로우 검증, 분 단위, 수십 개 이하
이 구조가 표준이 된 이유는 비용-가치 균형 때문입니다. 유닛은 싸고 빠르지만 좁은 검증, E2E는 비싸고 느리지만 실제 사용자 경험을 반영.
2.2 각 층이 잡는 버그의 종류
| 층 | 주로 잡는 버그 |
|---|---|
| 유닛 | 로직 오류, 경계 값 실수, 알고리즘 |
| 통합 | DB 스키마/쿼리 오류, API 계약 어긋남, 인증/권한 누락 |
| E2E | 사용자 플로우 단절, 브라우저 렌더링, 네트워크 지연 |
"유닛에서 통과한 코드가 E2E에서 터지는" 경험은 매우 흔합니다. 각자 다른 추상화 레벨에서 다른 종류의 실수를 막기 때문입니다.
3. 이 프로젝트의 각 층위
3.1 유닛 테스트: week-utils.test.ts
대상: getISOWeek, getWeekRange, utcToZonedParts 등 순수 함수.
describe("getWeekRange", () => {
it("Asia/Seoul — 2026-W15", () => {
const { startDate, endDate } = getWeekRange(2026, 15, "Asia/Seoul");
expect(startDate.toISOString()).toBe("2026-04-05T15:00:00.000Z");
expect(endDate.toISOString()).toBe("2026-04-12T14:59:59.999Z");
});
it("America/New_York (EDT)", () => {
const { startDate } = getWeekRange(2026, 15, "America/New_York");
expect(startDate.toISOString()).toBe("2026-04-06T04:00:00.000Z");
});
// ...DST, 경계, ICU '24' edge case 등
});
특징:
- 외부 의존성 없음 (DB, API 호출 없음)
- ms 단위 실행 → 빠름
- 경계값을 매우 꼼꼼하게 커버
잡는 버그:
- "DST 주에 경계가 어긋남"
- "ICU에서 자정을 24:00으로 반환하는 edge case"
- "1월 4일이 일요일인 해에 week 1 계산 오류"
3.2 유닛 테스트: project-status-log.test.ts
대상: logProjectStatusChange, getActiveProjectIdsInWeek 등 DB 접근 함수.
Prisma를 mock해서 "함수 자체의 로직"만 검증합니다:
import { vi } from "vitest";
const mockPrisma = {
projectStatusLog: {
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
},
};
it("같은 상태로 update면 새 로그를 만들지 않는다", async () => {
mockPrisma.projectStatusLog.findFirst.mockResolvedValue({ status: "진행중" });
const result = await logProjectStatusChange(mockPrisma, "p1", "진행중", "u1");
expect(result).toBe(false);
expect(mockPrisma.projectStatusLog.create).not.toHaveBeenCalled();
});
특징:
- Prisma mock → 실제 DB 왕복 없음 → 빠름
- 로직 검증에 집중 (
findFirst결과가 같으면create를 부르지 않는다)
잡는 버그:
- "같은 상태로 업데이트인데 중복 로그가 쌓임"
- "조직 격리 실패 (다른 org의 project ID가 섞임)"
3.3 API 통합 테스트: weekly-report.test.ts
대상: GET /api/reports/weekly 전체 플로우 — request parsing → auth → DB → response.
it("처음 조회 시 자동 생성 + active 과제만 포함", async () => {
// 시드 데이터
await seedOrgWithProjects([
{ status: "진행중" }, // active
{ status: "완료" }, // 비active
]);
const res = await GET(
new Request("http://localhost/api/reports/weekly?year=2026&week=15")
);
const data = await res.json();
expect(data.report.entries).toHaveLength(1);
expect(data.report.entries[0].project.status).toBe("진행중");
});
it("잘못된 year 파라미터 (1999, 2101)이면 400", async () => {
const res = await GET(
new Request("http://localhost/api/reports/weekly?year=1999&week=15")
);
expect(res.status).toBe(400);
});
특징:
- 실제 Prisma 호출 (test DB 또는 in-memory SQLite)
- Request 객체를 직접 생성해 라우트 핸들러에 주입
- Response status, body 검증
잡는 버그:
- "API 응답 계약 어긋남 (필드 누락/잘못된 타입)"
- "400/403/404 상태 코드 오류"
- "시드 데이터와 쿼리 필터 사이의 불일치"
- "RBAC가 의도대로 동작하지 않음"
3.4 컴포넌트 테스트
대상: React 컴포넌트의 렌더링과 상호작용.
import { render, screen } from "@testing-library/react";
import { WeeklyReportTable } from "./weekly-report-table";
it("과제 클릭 시 onSelectEntry 콜백이 호출된다", async () => {
const onSelect = vi.fn();
render(
<WeeklyReportTable
entries={[mockEntry]}
selectedProjectId={null}
onSelectEntry={onSelect}
viewMode="basic"
/>
);
await userEvent.click(screen.getByText(mockEntry.project.name));
expect(onSelect).toHaveBeenCalledWith(mockEntry.projectId);
});
특징:
- JSDOM 환경 → DOM 렌더링
- 외부 네트워크 호출 없음 (props로 데이터 주입)
- 사용자 상호작용 시뮬레이션
잡는 버그:
- "클릭 핸들러가 틀린 ID를 전달"
- "조건부 렌더링 오류 (진행중 없음 ≠ 빈 문자열)"
- "접근성 속성 누락"
3.5 E2E 테스트: e2e/saas/weekly-report.spec.ts
대상: 실제 브라우저에서 전체 사용자 플로우.
import { test, expect } from "@playwright/test";
test("사이드바 네비게이션 → 자동 생성 → 분할 뷰", async ({ page }) => {
await page.goto("/reports/weekly");
await expect(page.getByRole("heading", { name: "주간 진행 리포트" })).toBeVisible();
// 자동 생성된 entry가 있다면
const firstEntry = page.getByRole("row").first();
if (await firstEntry.isVisible()) {
await firstEntry.click();
await expect(page.getByText("특이사항")).toBeVisible();
}
});
특징:
- Playwright로 실제 브라우저 (Chromium) 띄움
- Next.js dev server에 실제 HTTP 요청
- 네트워크, 라우팅, 쿠키, 세션 모두 실제 환경
잡는 버그:
- "사이드바에 링크가 없음"
- "클릭했는데 navigation이 일어나지 않음"
- "브라우저에서 hydration mismatch"
- "API 호출은 성공하지만 UI가 반영 안 됨"
4. "어떤 테스트를 어느 층에 둘까" 판단
4.1 같은 버그를 여러 층에서 잡을 수 있는 경우
"잘못된 year 파라미터로 400 응답"은 유닛으로도, API 통합으로도, E2E로도 검증 가능합니다. 어디에 둘까?
원칙: 가장 저렴한 층에 둔다.
- 유닛: 파라미터 검증 함수만 떼어내 테스트 → ms 단위
- API 통합: Request 객체로 주입 → 수백 ms
- E2E: 브라우저로 URL 입력 → 수 초
순수 파라미터 검증이라면 유닛이 맞습니다. 하지만 이 프로젝트에서는 파라미터 검증이 route 핸들러 안에 인라인되어 있어 별도 함수로 분리돼 있지 않았습니다. 그래서 API 통합 테스트에 두고, E2E는 "정상 경로"만 검증합니다.
4.2 어떤 테스트는 E2E에만 둘 수 있다
- "사이드바 링크 클릭 → 페이지 전환 → 자동 생성 → 내용 표시" 같은 다단계 플로우
- 브라우저 히스토리 기반 "뒤로가기" 동작
- 여러 화면에 걸친 상태 유지
이런 건 유닛/통합으로는 검증할 수 없고, 반드시 E2E여야 합니다. 가격이 비싸지만 다른 방법이 없습니다.
4.3 어떤 테스트는 유닛에만 둔다
- 순수 함수의 경계값 (예:
getWeekRangeDST 전환) - 자료구조 변환 (예:
toTaskSummary) - 알고리즘 정확성 (예: ISO week number)
이런 건 E2E로 할 가치가 없습니다. 수백 경계 값을 브라우저로 테스트하면 몇 분 걸리고, 유닛으로는 밀리초 단위입니다.
4.4 결정 매트릭스
| 버그 유형 | 유닛 | 통합 | E2E |
|---|---|---|---|
| 알고리즘 오류 | ✅ | - | - |
| API 계약 불일치 | - | ✅ | 보조 |
| DB 쿼리 오류 | - | ✅ | - |
| UI 상호작용 | - | 보조 | ✅ |
| 사용자 플로우 | - | - | ✅ |
| 브라우저 호환 | - | - | ✅ |
"보조"는 주 커버리지가 다른 층에 있지만 이 층에서도 일부 잡힌다는 뜻입니다. 중복을 허용하는 게 나쁜 건 아니지만, 주 테스트는 한 층에만 두고 나머지는 보조로 두는 게 균형입니다.
5. 이 프로젝트의 독특한 결정
5.1 컴포넌트 테스트는 최소화
이 프로젝트는 컴포넌트 테스트를 많이 쓰지 않습니다. 이유:
- 대부분의 로직이 API route와 유틸 함수에 집중됨
- UI는 props를 받아 표시하는 "presentational"이 많음
- 컴포넌트 테스트보다 E2E 몇 개가 더 높은 신뢰를 제공
반면 완전히 interactive한 컴포넌트(WeeklyReportView 같은 분할 뷰 상태 관리)는 컴포넌트 테스트로 더 효율적일 수 있습니다. 판단은 케이스 바이 케이스.
5.2 E2E에 시드 의존 로직 최소화
이번 E2E는 10개 시나리오 중 **2개는 "시드 데이터가 없으면 test.skip"**으로 만들었습니다.
test("과제 클릭 → 분할 뷰", async ({ page }) => {
await page.goto("/reports/weekly");
const firstEntry = page.getByRole("row").first();
if (!(await firstEntry.isVisible())) {
test.skip(true, "시드 데이터 없음");
return;
}
await firstEntry.click();
// ...
});
왜?: E2E는 로컬/CI/스테이징 여러 환경에서 돌아갑니다. 환경마다 시드 상태가 달라서, "과제가 있어야만 통과"하는 테스트는 환경 의존성을 만듭니다. skip으로 관대하게 처리하면 주 흐름(네비게이션, 렌더링)은 여전히 검증되지만 데이터 의존 검증은 전용 테스트에만 둬야 합니다.
5.3 API 테스트의 Prisma mocking 전략
API 통합 테스트를 Prisma를 완전히 mock해서 돌립니다:
vi.mock("@/lib/prisma", () => ({
prisma: mockPrisma,
}));
장점:
- 실제 DB 필요 없음 → CI에서 PostgreSQL 컨테이너 띄우지 않아도 됨
- 빠름 (ms 단위)
단점:
- 쿼리 로직이 실제 DB 엔진에서 동작하는지는 미검증
- 복잡한 쿼리(JOIN, 집계)의 정확성은 E2E 또는 별도 integration test에서 확인 필요
이 프로젝트는 속도 우선이라 mock 전략을 택했고, 대신 E2E에서 실제 SQLite/PostgreSQL을 띄워 실제 DB 동작을 검증합니다.
6. 베스트 프랙티스
6.1 체크리스트
- [ ] 각 기능에 대해 "유닛 / 통합 / E2E" 세 층을 검토
- [ ] 가능하면 같은 버그는 가장 저렴한 층에서 잡기
- [ ] E2E는 사용자 플로우 위주, 세부 검증은 유닛/통합에 위임
- [ ] API 계약은 통합 테스트로 고정 (응답 shape 변경 시 test가 깨짐)
- [ ] 경계값/edge case는 유닛에 집중
- [ ] 컴포넌트 테스트는 상호작용이 있는 곳만
- [ ] 환경 의존적 E2E는
test.skip으로 관대하게
6.2 "왜 이 테스트가 여기 있는가" 주석
/**
* 유닛: 순수 함수 getWeekRange의 DST 전환 경계를 검증.
* 이 로직의 오류는 주간 리포트의 주차 경계가 어긋나는 심각한 버그로 이어짐.
*/
describe("getWeekRange with DST", () => {...});
/**
* API 통합: RBAC가 읽기 권한 없는 프로젝트를 리포트에서 제외하는지 검증.
* 유닛에서 mock RBAC로 검증하기는 어려워 API 통합에 둠.
*/
describe("GET /api/reports/weekly RBAC", () => {...});
테스트 파일의 목적이 명확해지면, 미래의 리뷰어가 "중복 아닌가?" 묻지 않습니다.
6.3 커버리지의 한계
총 테스트 수가 많다고 품질이 높은 건 아닙니다. 피해야 할 테스트:
- getter/setter만 검증하는 trivial 테스트 — 가치 없음
- 구현 세부사항을 고정시키는 테스트 — 리팩토링 방해
- 외부 라이브러리를 테스트하는 테스트 — Prisma/Next.js 자체를 테스트하지 말 것
- "일단 붙여둔" 테스트 — 의미 없이 숫자만 늘리는 것
테스트의 목적은 버그를 잡는 것이지, 숫자를 늘리는 것이 아닙니다.
7. FAQ
Q. 테스트 80개는 너무 많지 않나요?
A. 한 기능의 테스트 수가 아니라 한 PR의 테스트 수라면 적절합니다. 주간 리포트는 DB 스키마 + API 여러 개 + UI 여러 화면 + 유틸 함수 + RBAC까지 포함된 큰 기능입니다. 80개가 이 범위를 다 덮는 데 비해 결코 많지 않습니다.
Q. TDD로 가는 게 더 좋나요?
A. 이 프로젝트는 test-after 스타일입니다 — 기능 구현 후 테스트를 추가. TDD가 반드시 더 좋다는 법은 없고, 기능 탐색 단계에서는 test-after가 자연스럽습니다. 단, 리팩토링 국면에서는 TDD가 안전망 역할을 합니다. 상황별 판단.
Q. 유닛 테스트 커버리지 100%를 목표로 해야 하나요?
A. 아니요. 100% 커버리지는 "모든 코드 라인을 테스트가 실행한다"는 의미일 뿐, "모든 버그가 잡힌다"는 뜻이 아닙니다. 경험적으로 80% 정도에서 수익이 급감합니다. 그 이후는 trivial 테스트를 쌓는 작업이 됩니다.
Q. E2E를 CI에서 매번 돌리면 느리지 않나요?
A. 느립니다. 해결책:
- E2E 병렬 실행 (Playwright는 기본 지원)
- 주요 브랜치에서만 E2E 실행 (feature branch는 유닛+통합만)
- 야간 빌드에서 전체 E2E 실행
- 로컬에서는 변경 파일 관련 테스트만 실행
Q. 컴포넌트 테스트를 쓰지 않는데 UI 버그는 어떻게 잡나요?
A. 주로 E2E + 수동 테스트로 잡습니다. 작은 프로젝트에서는 충분합니다. 대규모 프로젝트라면 storybook + visual regression 테스트(Chromatic 등)가 효율적입니다. 팀 규모와 UI 복잡도에 따라 선택.
Q. 한 PR에 테스트가 없으면 거절해야 하나요?
A. 항상 그런 건 아닙니다. 문서 수정, 주석, 리팩토링 등은 테스트가 필요 없습니다. 로직이 바뀌는 변경에는 테스트가 있어야 합니다. 판단 기준: "이 PR이 의도한 동작 변화를 어떻게 검증하지?"
8. 참고 자료
- Martin Fowler - Test Pyramid
- Kent C. Dodds - Testing Trophy
- Vitest
- Playwright
- 관련 글: Vitest와 MSW로 React Hook 테스트하기
9. 다음 단계
한 기능을 수직으로 덮는 피라미드가 자리 잡으면, 다음 개선은 테스트 실행 속도입니다. Vitest의 병렬 실행, Playwright의 샤딩, CI 캐싱 같은 기법으로 피드백 루프를 1분 이내로 유지하는 게 목표입니다. 피드백이 빠르면 개발자가 테스트를 자주 돌리고, 자주 돌리면 조기에 버그를 잡고, 조기에 잡으면 기능이 빨리 안정화됩니다. 품질과 속도는 대립이 아니라 강화 루프입니다.