Skip to content
Published on

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

Authors

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

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 특징

  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);
});
  1. 하나의 테스트 = 하나의 assertion 관점
  2. 테스트 이름은 요구사항을 설명 — "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 작성 원칙

  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) — 무작위 입력으로 성질 확인
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 AService 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가지

  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

느린 건 측정되지 않은 것이다. 다음 글에서.