✍️ 필사 모드: 테스팅 완전 가이드 — Unit·Integration·E2E·Property·Contract·Mutation·TDD를 2025년 기준으로 한 번에 정리
한국어프롤로그 — "테스트가 있어야 리팩터가 있다"
2025년 4월, 당신의 팀엔 3년된 코드베이스가 있다.
- 테스트 커버리지 15%
- 리팩터 하려 해도 "건드리면 터질까봐" 겁남
- 배포마다 QA 팀이 수동 테스트 → 2주 사이클
- 새 기능 = 기존 기능 회귀 버그
해결책은 더 많은 사람도, 더 많은 문서도 아니다. 테스트다.
- Unit test로 순수 로직 보호
- Integration test로 DB/API 계층 보호
- E2E로 사용자 시나리오 보호
- Contract로 서비스 간 규약 보호
프론트엔드(Ep 16)를 만들었다면, 이제 신뢰성을 만든다. 테스트는 코드를 느리게 하는 게 아니라, 미래의 속도를 지킨다.
이 글은 Season 2 Ep 17 — 테스팅 완전 가이드. Testing Pyramid vs Trophy, Test Doubles 5종류, Property-based/Contract/Mutation Testing, TDD의 진짜 의미, CI 통합 전략까지.
1부 — Testing Pyramid vs Trophy vs Honeycomb
Pyramid (Mike Cohn, 2009)
/\ E2E (적음)
/ \
/----\ Integration (중간)
/ \
/--------\ Unit (많음)
규칙: Unit 많이, E2E 적게. E2E는 느리고 깨지기 쉬움.
Testing Trophy (Kent C. Dodds, 2019)
┌────────┐
│ E2E │
├────────┤
│ │
│ Integ- │ ← 가장 큰 영역
│ ration │
│ │
├────────┤
│ Unit │
├────────┤
│ Static │
└────────┘
변화: Integration이 중심. 프론트엔드는 "컴포넌트 = 작은 앱" → Integration이 현실적. 추가: Static (TypeScript, ESLint) — 타입이 많은 버그 잡음.
Honeycomb (Spotify)
┌────┐
│Integ│
├────┤
│Impl│ (구현 테스트 적게)
├────┤
│Integ│
└────┘
철학: Integrated testing 중심, 단위 테스트 최소화. 마이크로서비스 환경에 적합.
2025 현실 — "Pyramid는 죽었다"는 너무 나감
합의점:
- 순수 함수/비즈니스 로직 → Unit
- DB, API, 외부 의존성 → Integration
- 사용자 여정 (결제, 회원가입) → E2E
- 마이크로서비스 → Contract Testing 추가
2부 — Unit Test — 무엇을 테스트하나
Good Unit Test
// calculateTotalPrice.ts
export function calculateTotalPrice(items: Item[], couponRate: number): number {
if (couponRate < 0 || couponRate > 1) throw new Error('Invalid coupon rate');
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
return subtotal * (1 - couponRate);
}
// calculateTotalPrice.test.ts
import { describe, it, expect } from 'vitest';
import { calculateTotalPrice } from './calculateTotalPrice';
describe('calculateTotalPrice', () => {
it('returns subtotal when no coupon', () => {
expect(calculateTotalPrice([{ price: 100, quantity: 2 }], 0)).toBe(200);
});
it('applies 10% coupon', () => {
expect(calculateTotalPrice([{ price: 100, quantity: 2 }], 0.1)).toBe(180);
});
it('throws on invalid coupon rate', () => {
expect(() => calculateTotalPrice([], -0.1)).toThrow();
expect(() => calculateTotalPrice([], 1.1)).toThrow();
});
it('handles empty items', () => {
expect(calculateTotalPrice([], 0.1)).toBe(0);
});
});
좋은 Unit Test 특징
-
F.I.R.S.T
- Fast — 밀리초 단위
- Independent — 순서 상관없음
- Repeatable — 몇 번 돌려도 같음
- Self-validating — pass/fail 명확
- Timely — 코드와 함께 작성
-
Given-When-Then (또는 AAA: Arrange-Act-Assert)
it('applies discount', () => {
// Given (Arrange)
const items = [{ price: 100, quantity: 2 }];
// When (Act)
const result = calculateTotalPrice(items, 0.1);
// Then (Assert)
expect(result).toBe(180);
});
- 하나의 테스트 = 하나의 assertion 관점
- 테스트 이름은 요구사항을 설명 — "appliesDiscount" 아니라 "applies 10% coupon to total"
안티패턴
// 나쁜 예: 구현 세부사항 테스트
it('calls map function', () => {
const spy = vi.spyOn(Array.prototype, 'map');
calculateTotalPrice([...]);
expect(spy).toHaveBeenCalled(); // ← 왜 map을 썼는지는 구현 세부사항
});
// 좋은 예: 입력 → 출력
it('calculates correct total', () => {
expect(calculateTotalPrice([...])).toBe(180); // ← 행동을 테스트
});
원칙: "구현(How)이 아니라 행동(What)을 테스트"
3부 — Test Doubles 5종류
Gerard Meszaros의 xUnit Test Patterns 용어.
1. Dummy — 전달되지만 사용 안 됨
const dummyLogger = {} as Logger;
processOrder(order, dummyLogger); // Logger 필요한데 실제로 호출 안 됨
2. Stub — 고정된 응답
const stubUserRepo = {
findById: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }),
};
const user = await stubUserRepo.findById(1);
3. Fake — 간단한 실제 구현
class InMemoryUserRepo implements UserRepo {
private users = new Map();
async save(user: User) { this.users.set(user.id, user); }
async findById(id: number) { return this.users.get(id); }
}
쓰임: 테스트에서 진짜 DB 대신 InMemory로 바꿈.
4. Spy — 호출 기록
const emailSpy = vi.fn();
sendWelcomeEmail('user@example.com', emailSpy);
expect(emailSpy).toHaveBeenCalledWith('user@example.com');
expect(emailSpy).toHaveBeenCalledTimes(1);
5. Mock — 기대한 호출 + 고정 응답
const mockPaymentGateway = {
charge: vi.fn().mockResolvedValue({ success: true, txId: 'abc' }),
};
await checkout(order, mockPaymentGateway);
expect(mockPaymentGateway.charge).toHaveBeenCalledWith(100, expect.any(String));
Mock vs Stub: Mock은 기대를 검증, Stub은 데이터만 제공.
언제 뭘 쓸까
순수 함수: Double 필요 없음
외부 API: Stub (고정 응답) 또는 MSW (네트워크 레벨 인터셉트)
외부 side effect (이메일): Spy로 호출 확인
복잡한 도메인 객체: Fake (InMemory) 선호
검증 중심: Mock
MSW — 네트워크 레벨 Mock
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Alice' });
})
);
beforeAll(() => server.listen());
afterAll(() => server.close());
// 테스트 코드는 진짜 fetch를 써도 됨
test('fetches user', async () => {
const res = await fetch('/api/users/1');
expect(await res.json()).toEqual({ id: '1', name: 'Alice' });
});
장점: 코드 변경 없이 네트워크만 가로챔. 프론트/백 모두 사용.
4부 — Integration Test — DB/API/외부 의존성
Testcontainers — 진짜 DB로 테스트
import { PostgreSqlContainer } from '@testcontainers/postgresql';
let container;
let db;
beforeAll(async () => {
container = await new PostgreSqlContainer('postgres:16-alpine').start();
db = new PrismaClient({ datasources: { db: { url: container.getConnectionUri() } } });
await db.$executeRaw`CREATE TABLE users (id SERIAL PRIMARY KEY, name TEXT)`;
}, 30_000);
afterAll(async () => {
await db.$disconnect();
await container.stop();
});
test('creates user', async () => {
const user = await userService.create({ name: 'Alice' });
const found = await db.user.findUnique({ where: { id: user.id } });
expect(found?.name).toBe('Alice');
});
장점: mock 없이 진짜 DB. "Works on my machine" 문제 해결. 단점: 시간 걸림 (컨테이너 시작 30초+).
Database 상태 관리
// 각 테스트 전에 초기화
beforeEach(async () => {
await db.$executeRaw`TRUNCATE users CASCADE`;
});
// 또는 트랜잭션으로 롤백
beforeEach(async () => {
await db.$executeRaw`BEGIN`;
});
afterEach(async () => {
await db.$executeRaw`ROLLBACK`;
});
API Integration Test
import { test, expect } from 'vitest';
import { app } from '../src/app';
import request from 'supertest';
test('POST /users creates user', async () => {
const res = await request(app)
.post('/users')
.send({ name: 'Alice', email: 'a@example.com' });
expect(res.status).toBe(201);
expect(res.body).toMatchObject({ name: 'Alice' });
});
5부 — E2E — 사용자 시나리오
Playwright (2025 표준)
import { test, expect } from '@playwright/test';
test.describe('Shopping flow', () => {
test('user can buy an item', async ({ page }) => {
await page.goto('/');
await page.getByPlaceholder('Search').fill('headphones');
await page.getByRole('button', { name: 'Search' }).click();
await page.getByRole('link', { name: /sony/i }).first().click();
await page.getByRole('button', { name: 'Add to cart' }).click();
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page.getByTestId('cart-item')).toHaveCount(1);
await page.getByRole('button', { name: 'Checkout' }).click();
await page.getByLabel('Card number').fill('4242424242424242');
await page.getByLabel('Expiry').fill('12/30');
await page.getByLabel('CVC').fill('123');
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page.getByText('Order confirmed')).toBeVisible();
});
});
E2E 작성 원칙
- User-facing selector 사용:
getByRole,getByLabel,getByText - 구현 세부사항 피하기: CSS 클래스 selector 피함
- 안정적인 data-testid — 필요한 경우
- 네트워크 모킹 — 외부 API는 MSW or
page.route - screenshot/trace 수집 — CI 실패 디버깅용
Flaky Test 대응
// ❌ 시간 기반 대기
await page.waitForTimeout(2000);
// ✅ 조건 기반 대기
await expect(page.getByText('Loaded')).toBeVisible();
await page.waitForLoadState('networkidle');
await expect(async () => {
expect(await getCount()).toBe(10);
}).toPass({ timeout: 5000 });
Playwright는 자동 재시도가 기본. expect는 조건 충족까지 대기.
6부 — Property-based Testing
예시 기반 vs 속성 기반
// 예시 기반 (Example-based) — 우리가 익숙한 방식
test('sum commutes', () => {
expect(sum(1, 2)).toBe(sum(2, 1));
expect(sum(3, 5)).toBe(sum(5, 3));
});
// 속성 기반 (Property-based) — 무작위 입력으로 성질 확인
import { fc } from 'fast-check';
test('sum commutes (property)', () => {
fc.assert(fc.property(
fc.integer(), fc.integer(),
(a, b) => expect(sum(a, b)).toBe(sum(b, a))
));
});
장점: 수백 개의 무작위 입력 → 우리가 놓친 edge case 발견.
실제로 버그 찾는 예
// reverse 함수
function reverse(arr: number[]) { return arr.slice().reverse(); }
// 속성: reverse(reverse(x)) === x
fc.assert(fc.property(
fc.array(fc.integer()),
(arr) => expect(reverse(reverse(arr))).toEqual(arr)
));
Shrinking — 최소 반례 찾기
fast-check는 실패한 입력을 작은 것부터 다시 찾아 "이것도 실패?"를 반복. 결과: "[1, 2, 3, 100]에서 실패" → "[1, 2]에서도 실패" → "[1]에서도 실패" → 최소 반례 보고.
Hypothesis (Python), PropCheck (Erlang), QuickCheck (Haskell)
fast-check는 QuickCheck 계보. 언어별로 같은 개념.
언제 쓰나
- 파서 (입력 X 무한)
- 시리얼라이저/디시리얼라이저 (
parse(format(x)) === x) - 정렬, 자료구조
- 도메인 규칙 (회원가입 조건, 주문 검증)
7부 — Contract Testing
마이크로서비스의 문제
Service A → Service B 호출
Service B 스키마 변경 → Service A 배포 시점에 발견
Pact — Consumer-Driven Contracts
Consumer (Service A) 테스트:
import { PactV3 } from '@pact-foundation/pact';
const provider = new PactV3({ consumer: 'ServiceA', provider: 'ServiceB' });
provider
.given('user 1 exists')
.uponReceiving('a request for user 1')
.withRequest({ method: 'GET', path: '/users/1' })
.willRespondWith({
status: 200,
body: { id: 1, name: 'Alice' },
});
await provider.executeTest(async (mockServer) => {
const client = new UserClient(mockServer.url);
const user = await client.getUser(1);
expect(user.name).toBe('Alice');
});
결과: pact/ServiceA-ServiceB.json 파일 생성 → Pact Broker에 업로드.
Provider (Service B) 검증:
import { Verifier } from '@pact-foundation/pact';
await new Verifier({
provider: 'ServiceB',
providerBaseUrl: 'http://localhost:3000',
pactBrokerUrl: 'https://pact-broker.example.com',
}).verifyProvider();
효과: Service B가 규약 깨면 배포 전 CI에서 실패.
2025 대안 — OpenAPI / AsyncAPI + Schemathesis
OpenAPI 스키마에서 자동으로 테스트 생성. Pact보다 진입 장벽 낮음.
8부 — Mutation Testing
테스트의 테스트
코드: if (x > 0) return 'positive';
뮤턴트 (인위적 버그):
- if (x >= 0) return 'positive';
- if (x < 0) return 'positive';
- if (x > 1) return 'positive';
테스트가 뮤턴트를 감지(kill)하면 좋은 테스트
통과하면 (survive) 커버리지 있지만 실효성 없음
Stryker (JS), Mutmut (Python), PIT (Java)
npx stryker run
결과 예시:
Mutation Score: 72%
Killed: 144
Survived: 56 ← 테스트가 못 잡는 뮤턴트
Timeout: 8
NoCoverage: 12
언제 쓸까
- 커버리지는 높은데 믿음이 안 갈 때
- 중요한 비즈니스 로직 (결제, 인증)
- 오픈소스 라이브러리 품질 관리
주의: 전체 코드베이스에 돌리면 시간 엄청 걸림. 모듈별로 선택적 실행.
9부 — Snapshot Testing — 현명하게
Jest/Vitest Snapshot
test('renders button', () => {
const { container } = render(<Button>Click</Button>);
expect(container).toMatchSnapshot();
});
1회차: __snapshots__ 폴더에 저장
이후: 변경되면 실패 → --update-snapshots로 갱신
문제점
- 버튼 텍스트 바꿨다? → snapshot 10개 깨짐 → 자동 update로 확인 없이 통과 (의미 없음)
- 큰 snapshot은 리뷰 불가 — diff 수백 줄
바람직한 사용
// 작은 범위만
expect(component.getByTestId('price')).toMatchInlineSnapshot('"$100.00"');
// 또는 구조만
expect(Object.keys(obj)).toMatchSnapshot();
대안: Testing Library toBe... 쿼리
expect(screen.getByRole('button')).toHaveTextContent('Click');
expect(screen.getByRole('button')).toBeEnabled();
명시적. snapshot보다 읽기 쉬움.
10부 — TDD vs BDD vs TLD
TDD (Test-Driven Development)
1. RED — 실패하는 테스트 먼저
2. GREEN — 테스트 통과할 최소 코드
3. REFACTOR — 설계 개선
4. 반복
효과: 테스트 존재 보장, 과도 설계 방지, 피드백 루프 짧음 실전 함정: "테스트 먼저"가 항상 맞는 건 아님. 탐색적 코딩에는 비효율
BDD (Behavior-Driven Development)
Feature: User sign-up
Scenario: Valid email and password
Given a guest on the sign-up page
When they fill email "a@b.com" and password "P@ss123"
And click "Sign up"
Then they see "Welcome"
도구: Cucumber, Jest-cucumber, Playwright + spec files 장점: 비개발자와 공유 가능 단점: 추가 레이어, 과도한 Gherkin은 Noise
TLD (Test-Last Development) — 현실
많은 팀이 "코드 작성 → 테스트 추가" 순으로 간다. 나쁜 건 아니다 — 테스트가 있으면 OK.
2025 현실
- TDD: 프레임워크 기여자, 수학적 로직, 복잡한 도메인
- BDD: 큰 조직, 요구사항 공유 중요
- TLD: 대부분 현업 팀. 문제 없음
핵심: "테스트가 있다" > "없다". 순서는 팀 문화.
11부 — 테스트 커버리지의 진실
Line vs Branch vs Function Coverage
function classify(n: number) {
if (n > 0) return 'positive';
if (n < 0) return 'negative';
return 'zero';
}
// line coverage 100%
test('covers all', () => {
expect(classify(5)).toBe('positive');
// n < 0 가지는 실행 안 됨
});
80% 규칙의 함정
커버리지 80%는 목표가 아니라 척도
핵심 비즈니스 로직은 95%+, 보일러플레이트는 50%도 OK
"커버리지 90% 되는데 왜 버그가?" → 맞는 것만 테스트했을 뿐
진짜 중요한 것
- 핵심 로직: 결제, 인증, 권한 → 100% + Mutation
- 복잡한 알고리즘: Property-based
- 사용자 flow: E2E
- UI 세부사항: 적게, Snapshot도 OK
- 외부 의존성 래퍼: Integration test
12부 — CI 통합 전략
테스트 피라미드 CI 실행
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
static:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- run: pnpm install
- run: pnpm typecheck
- run: pnpm lint
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- run: pnpm install
- run: pnpm test:unit
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env: { POSTGRES_PASSWORD: test }
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- run: pnpm install
- run: pnpm test:integration
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- run: pnpm install
- run: pnpm playwright install --with-deps chromium
- run: pnpm test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-trace
path: test-results/
Parallelization
Vitest: vitest run --shard=1/4 (sharding)
Playwright: workers: 4 (기본), matrix로 Projects 병렬
Flaky Test 관리
- Retry:
test.retries(2)— 2번까지 재시도 - Quarantine: 실패하는 테스트를 별도 job으로
- Metrics: 플레이키 테스트 비율 모니터링
Test Impact Analysis
변경된 코드가 영향 주는 테스트만 실행
도구: Vitest --changed, Nx affected, Turborepo
효과: PR에서 전체 테스트 대신 관련 테스트만 → 10x 빠름.
13부 — 6개월 로드맵
1개월차: Vitest로 Unit Test 익숙해지기. F.I.R.S.T, AAA 패턴 2개월차: Testing Library로 React 컴포넌트 테스트. MSW로 API mock 3개월차: Playwright E2E. Page Object Pattern, 안정적 selector 4개월차: Integration test with Testcontainers. 실제 DB로 테스트 5개월차: Property-based (fast-check). Contract testing (Pact) 기본 6개월차: Mutation testing (Stryker) 실험. Test Impact Analysis로 CI 최적화
14부 — 체크리스트 12개
- Vitest/Jest 기본 설정 + watch 모드
- Testing Library + MSW 조합
- Playwright E2E + CI trace 업로드
- Testcontainers로 DB integration test
- 커버리지 보고 (Codecov, Coveralls)
- 핵심 로직 Property-based test
- 서비스 간 Contract test (Pact or OpenAPI)
- Flaky test 모니터링 + quarantine
- CI parallelization (sharding, matrix)
- Test Impact Analysis (changed files)
- Snapshot은 작게, 구조만
- "RED-GREEN-REFACTOR" 루프 팀 문화
15부 — 안티패턴 10가지
- 구현 세부사항 테스트 → 리팩터 시 테스트 전부 깨짐
- Setup 30줄짜리 테스트 → 의미 파악 불가, 전용 헬퍼로
- Mock 모든 것 → "내 Mock이 맞는지"만 테스트, 현실 X
- 큰 Snapshot → 변경 시 무의식적 accept
- 커버리지 100% 강제 → 의미 없는 테스트 양산
- E2E로 모든 것 → 느리고 플레이키
- 테스트 순서 의존 → 병렬 실행 시 실패
- Sleep으로 대기 → 느리고 플레이키, 조건 기반 대기 써라
- 프로덕션 DB로 테스트 → 데이터 오염
- CI에서만 테스트 → 로컬 피드백 루프 없음
마무리 — "테스트는 속도를 위한 투자"
"테스트 쓸 시간 없어요" — 가장 흔한 말이자 가장 비싼 핑계. 테스트는 현재의 속도를 줄여서 미래의 속도를 지키는 투자다.
2025년 테스트 전략의 핵심:
- 계층별 다른 테스트 — Unit/Integration/E2E/Contract
- 구현이 아닌 행동 — 리팩터에 견디는 테스트
- Flaky는 죽음 — 신뢰 잃은 테스트는 삭제가 나음
- CI가 빠르면 테스트도 많이 쓴다 — Parallelization, TIA
좋은 테스트의 특징 한 줄: "깨지면 뭐가 잘못됐는지 즉시 안다".
다음 글은 Season 2 Ep 18 — 성능 엔지니어링 완전 가이드. 프로파일링, Flame Graph, eBPF, JIT, 메모리 관리, 벤치마킹 방법론까지. 느린 코드는 돈이다 — 매월 클라우드 비용으로, 사용자 이탈로.
다음 글 예고 — "성능 엔지니어링 완전 가이드: 프로파일링·Flame Graph·JIT·메모리·벤치마킹"
Season 2 Ep 18은:
- 프로파일링 도구 (perf, eBPF, Pyroscope, Parca)
- Flame Graph 읽는 법
- 메모리 누수 추적 (heap snapshot)
- 벤치마킹 방법론 (warmup, outlier)
- JIT vs AOT 컴파일
- Data-oriented Design
- Cache Line, False Sharing
느린 건 측정되지 않은 것이다. 다음 글에서.
현재 단락 (1/428)
2025년 4월, 당신의 팀엔 3년된 코드베이스가 있다.