필사 모드: 테스팅 완전 가이드 — 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
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 특징
1. **F.I.R.S.T**
- **Fast** — 밀리초 단위
- **Independent** — 순서 상관없음
- **Repeatable** — 몇 번 돌려도 같음
- **Self-validating** — pass/fail 명확
- **Timely** — 코드와 함께 작성
2. **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);
});
3. **하나의 테스트 = 하나의 assertion 관점**
4. **테스트 이름은 요구사항을 설명** — "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
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로 테스트
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
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 표준)
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 작성 원칙
1. **User-facing selector 사용**: `getByRole`, `getByLabel`, `getByText`
2. **구현 세부사항 피하기**: CSS 클래스 selector 피함
3. **안정적인 data-testid** — 필요한 경우
4. **네트워크 모킹** — 외부 API는 MSW or `page.route`
5. **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) — 무작위 입력으로 성질 확인
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) 테스트**:
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) 검증**:
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가지
1. **구현 세부사항 테스트** → 리팩터 시 테스트 전부 깨짐
2. **Setup 30줄짜리 테스트** → 의미 파악 불가, 전용 헬퍼로
3. **Mock 모든 것** → "내 Mock이 맞는지"만 테스트, 현실 X
4. **큰 Snapshot** → 변경 시 무의식적 accept
5. **커버리지 100% 강제** → 의미 없는 테스트 양산
6. **E2E로 모든 것** → 느리고 플레이키
7. **테스트 순서 의존** → 병렬 실행 시 실패
8. **Sleep으로 대기** → 느리고 플레이키, 조건 기반 대기 써라
9. **프로덕션 DB로 테스트** → 데이터 오염
10. **CI에서만 테스트** → 로컬 피드백 루프 없음
마무리 — "테스트는 속도를 위한 투자"
"테스트 쓸 시간 없어요" — 가장 흔한 말이자 가장 비싼 핑계.
테스트는 **현재의 속도를 줄여서 미래의 속도를 지키는 투자**다.
2025년 테스트 전략의 핵심:
1. **계층별 다른 테스트** — Unit/Integration/E2E/Contract
2. **구현이 아닌 행동** — 리팩터에 견디는 테스트
3. **Flaky는 죽음** — 신뢰 잃은 테스트는 삭제가 나음
4. **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년된 코드베이스가 있다.