Skip to content

필사 모드: 테스팅 완전 가이드 — Unit·Integration·E2E·Property·Contract·Mutation·TDD를 2025년 기준으로 한 번에 정리

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

프롤로그 — "테스트가 있어야 리팩터가 있다"

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년된 코드베이스가 있다.

작성 글자: 0원문 글자: 13,108작성 단락: 0/428