한 기능을 수직으로 덮는 테스트 피라미드: Weekly Report 기능의 50+ 테스트 사례 연구

주간 리포트 하나를 출시하는 데 유닛 41개 + E2E 10개 = 51개 테스트가 필요했다. 같은 기능을 왜 여러 층위에서 반복 검증하는가? 피라미드 각 층의 역할과 '어떤 테스트를 어디에 둘지'의 구체적 선택.

1. 문제 상황

1.1 "이 테스트가 너무 많은 거 아닌가요?"

주간 리포트 기능의 PR을 보면서 팀원이 물었습니다.

"유닛 테스트, 컴포넌트 테스트, API 테스트, E2E 테스트까지 같은 기능을 네 번 검증하고 있는데 중복 아니에요?"

합리적인 질문입니다. 같은 "리포트 자동 생성" 플로우가:

  • project-status-log.test.tsgetActiveProjectIdsInWeek 유닛 테스트
  • 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 어떤 테스트는 유닛에만 둔다

  • 순수 함수의 경계값 (예: getWeekRange DST 전환)
  • 자료구조 변환 (예: 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. 참고 자료


9. 다음 단계

한 기능을 수직으로 덮는 피라미드가 자리 잡으면, 다음 개선은 테스트 실행 속도입니다. Vitest의 병렬 실행, Playwright의 샤딩, CI 캐싱 같은 기법으로 피드백 루프를 1분 이내로 유지하는 게 목표입니다. 피드백이 빠르면 개발자가 테스트를 자주 돌리고, 자주 돌리면 조기에 버그를 잡고, 조기에 잡으면 기능이 빨리 안정화됩니다. 품질과 속도는 대립이 아니라 강화 루프입니다.