Skip to content
Published on

소프트웨어 테스트 전략 완전 가이드 2025: 단위/통합/E2E, TDD, 테스트 피라미드

Authors

도입

2024년, Kent Beck은 "테스트를 작성하지 않는 것은 안전벨트 없이 운전하는 것과 같다"고 강조했습니다. 소프트웨어가 점점 복잡해지는 현대에서, 테스트 전략은 선택이 아닌 필수입니다. Netflix는 1만 개 이상의 마이크로서비스에서 매일 수백만 건의 테스트를 실행하며, Google은 전체 코드베이스에 대해 지속적으로 테스트를 돌리고 있습니다.

하지만 많은 팀이 "어떤 테스트를, 얼마나, 어떻게 작성해야 하는가?"라는 근본적인 질문에 답하지 못합니다. 테스트 피라미드부터 TDD/BDD, 모킹 전략, CI 통합까지 -- 이 가이드는 2025년 현재 실무에서 필요한 테스트 전략을 체계적으로 다룹니다.


1. 테스트 피라미드와 그 변형들

1.1 전통적 테스트 피라미드

Mike Cohn이 2009년에 제안한 테스트 피라미드는 테스트 전략의 기본 프레임워크입니다.

        /\
       /  \        E2E Tests (10%)
      /    \       - 느리고 비용 높음
     /------\      - 전체 시스템 검증
    /        \     Integration Tests (20%)
   /          \    - 서비스 간 연동
  /------------\   Unit Tests (70%)
 /              \  - 빠르고 저렴
/________________\ - 비즈니스 로직 검증
계층비율실행 속도유지보수 비용신뢰도
E2E10%느림 (분 단위)높음매우 높음
통합20%보통 (초 단위)중간높음
단위70%빠름 (ms 단위)낮음중간

1.2 테스트 트로피 (Testing Trophy)

Kent C. Dodds가 제안한 테스트 트로피는 프론트엔드 관점에서 통합 테스트에 더 큰 비중을 둡니다.

       ___
      | E |  E2E (소수)
      |___|
     /     \
    / Integ \  Integration (가장 많이)
   /_________\
   |  Unit   |  Unit (적당히)
   |_________|
   |  Static |  Static Analysis (기본)
   |_________|

핵심 철학: "사용자가 소프트웨어를 사용하는 방식과 유사하게 테스트할수록 더 높은 신뢰도를 얻는다."

1.3 테스트 다이아몬드 (Testing Diamond)

대규모 백엔드 시스템에서 Spotify가 채택한 접근법입니다.

      /\       E2E (소수)
     /  \
    /    \
   /------\    Integration (많이)
   \      /    - DB, API, 메시지 큐
    \    /
     \  /      Unit (중간)
      \/       - 순수 비즈니스 로직

1.4 어떤 모델을 선택해야 하는가?

프로젝트 유형권장 모델이유
프론트엔드 SPA테스트 트로피컴포넌트 통합이 핵심
백엔드 API테스트 다이아몬드DB/외부 서비스 연동 중요
마이크로서비스테스트 피라미드서비스 격리가 핵심
풀스택 모노리스하이브리드레이어별 최적 전략 혼합

2. 단위 테스트 (Unit Testing)

2.1 AAA 패턴

모든 단위 테스트의 기본 구조입니다.

// Arrange-Act-Assert 패턴
describe('calculateDiscount', () => {
  it('VIP 고객에게 20% 할인을 적용한다', () => {
    // Arrange (준비)
    const customer = createCustomer({ tier: 'VIP' });
    const order = createOrder({ total: 10000 });

    // Act (실행)
    const result = calculateDiscount(customer, order);

    // Assert (검증)
    expect(result.discountRate).toBe(0.2);
    expect(result.finalPrice).toBe(8000);
  });
});

2.2 Jest vs Vitest 비교

// ========== Jest 설정 ==========
// jest.config.ts
export default {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  setupFilesAfterSetup: ['<rootDir>/jest.setup.ts'],
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: [
    'src/**/*.{ts,tsx}',
    '!src/**/*.d.ts',
  ],
};

// ========== Vitest 설정 ==========
// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
    globals: true,
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
    },
  },
  resolve: {
    alias: { '@': '/src' },
  },
});
기능JestVitest
실행 속도보통매우 빠름 (Vite 기반)
설정별도 설정 필요Vite 설정 공유
ESM 지원실험적네이티브
호환성Jest APIJest 호환 API
HMR없음지원 (watch 모드)
생태계매우 넓음빠르게 성장 중

2.3 Pytest (Python 백엔드)

# test_order_service.py
import pytest
from decimal import Decimal
from order_service import OrderService, Order, OrderItem

class TestOrderService:
    """주문 서비스 단위 테스트"""

    @pytest.fixture
    def order_service(self):
        return OrderService()

    @pytest.fixture
    def sample_items(self):
        return [
            OrderItem(name="노트북", price=Decimal("1200000"), quantity=1),
            OrderItem(name="마우스", price=Decimal("50000"), quantity=2),
        ]

    def test_calculate_total(self, order_service, sample_items):
        """총 금액을 올바르게 계산한다"""
        total = order_service.calculate_total(sample_items)
        assert total == Decimal("1300000")

    def test_apply_bulk_discount(self, order_service, sample_items):
        """100만원 이상 주문 시 10% 할인을 적용한다"""
        total = order_service.calculate_total(sample_items)
        discounted = order_service.apply_discount(total, "BULK")
        assert discounted == Decimal("1170000")

    @pytest.mark.parametrize("coupon_code,expected_discount", [
        ("WELCOME10", Decimal("0.10")),
        ("VIP20", Decimal("0.20")),
        ("SPECIAL30", Decimal("0.30")),
        ("INVALID", Decimal("0.00")),
    ])
    def test_coupon_discount_rates(self, order_service, coupon_code, expected_discount):
        """쿠폰 코드별 할인율을 검증한다"""
        rate = order_service.get_coupon_discount_rate(coupon_code)
        assert rate == expected_discount

2.4 JUnit 5 (Java/Spring Boot)

// OrderServiceTest.java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {

    @Mock
    private OrderRepository orderRepository;

    @Mock
    private PaymentGateway paymentGateway;

    @InjectMocks
    private OrderService orderService;

    @Nested
    @DisplayName("주문 생성 테스트")
    class CreateOrderTest {

        @Test
        @DisplayName("유효한 주문을 성공적으로 생성한다")
        void shouldCreateOrderSuccessfully() {
            // Arrange
            var request = new CreateOrderRequest("user-1", List.of(
                new OrderItem("item-1", 2, BigDecimal.valueOf(10000))
            ));
            when(orderRepository.save(any(Order.class)))
                .thenReturn(Order.builder().id("order-1").build());

            // Act
            var result = orderService.createOrder(request);

            // Assert
            assertThat(result.getId()).isEqualTo("order-1");
            verify(orderRepository).save(any(Order.class));
        }

        @Test
        @DisplayName("빈 아이템 목록으로 주문 시 예외를 던진다")
        void shouldThrowExceptionForEmptyItems() {
            var request = new CreateOrderRequest("user-1", List.of());

            assertThrows(InvalidOrderException.class,
                () -> orderService.createOrder(request));
        }
    }

    @ParameterizedTest
    @CsvSource({
        "10000, 0, 10000",
        "10000, 10, 9000",
        "50000, 20, 40000",
    })
    @DisplayName("할인율에 따른 최종 가격을 계산한다")
    void shouldCalculateFinalPrice(
            int price, int discountPercent, int expected) {
        var result = orderService.calculateFinalPrice(
            BigDecimal.valueOf(price), discountPercent);
        assertThat(result).isEqualByComparingTo(
            BigDecimal.valueOf(expected));
    }
}

3. 통합 테스트 (Integration Testing)

3.1 데이터베이스 통합 테스트 with Testcontainers

// order.integration.test.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { PrismaClient } from '@prisma/client';

describe('OrderRepository Integration', () => {
  let container;
  let prisma: PrismaClient;

  beforeAll(async () => {
    // 실제 PostgreSQL 컨테이너 시작
    container = await new PostgreSqlContainer('postgres:16')
      .withDatabase('testdb')
      .withUsername('test')
      .withPassword('test')
      .start();

    // Prisma 연결
    prisma = new PrismaClient({
      datasources: {
        db: { url: container.getConnectionUri() },
      },
    });

    // 마이그레이션 실행
    await prisma.$executeRaw`CREATE TABLE orders (
      id SERIAL PRIMARY KEY,
      user_id VARCHAR(255) NOT NULL,
      total DECIMAL(10,2) NOT NULL,
      status VARCHAR(50) DEFAULT 'pending',
      created_at TIMESTAMP DEFAULT NOW()
    )`;
  }, 60000);

  afterAll(async () => {
    await prisma.$disconnect();
    await container.stop();
  });

  it('주문을 생성하고 조회할 수 있다', async () => {
    // 생성
    const order = await prisma.order.create({
      data: {
        userId: 'user-123',
        total: 50000,
        status: 'pending',
      },
    });

    // 조회
    const found = await prisma.order.findUnique({
      where: { id: order.id },
    });

    expect(found).toBeDefined();
    expect(found?.userId).toBe('user-123');
    expect(found?.total).toBe(50000);
  });

  it('사용자별 주문 목록을 조회할 수 있다', async () => {
    // 여러 주문 생성
    await prisma.order.createMany({
      data: [
        { userId: 'user-A', total: 10000, status: 'completed' },
        { userId: 'user-A', total: 20000, status: 'pending' },
        { userId: 'user-B', total: 30000, status: 'completed' },
      ],
    });

    const userAOrders = await prisma.order.findMany({
      where: { userId: 'user-A' },
      orderBy: { createdAt: 'desc' },
    });

    expect(userAOrders).toHaveLength(2);
  });
});

3.2 Python Testcontainers

# test_user_repository.py
import pytest
from testcontainers.postgres import PostgresContainer
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.models import User, Base
from app.repositories import UserRepository

@pytest.fixture(scope="module")
def postgres_container():
    with PostgresContainer("postgres:16") as postgres:
        yield postgres

@pytest.fixture(scope="module")
def db_session(postgres_container):
    engine = create_engine(postgres_container.get_connection_url())
    Base.metadata.create_all(engine)
    Session = sessionmaker(bind=engine)
    session = Session()
    yield session
    session.close()

class TestUserRepository:
    def test_create_and_find_user(self, db_session):
        repo = UserRepository(db_session)

        # 생성
        user = repo.create(name="홍길동", email="hong@example.com")
        assert user.id is not None

        # 조회
        found = repo.find_by_id(user.id)
        assert found.name == "홍길동"
        assert found.email == "hong@example.com"

    def test_find_by_email(self, db_session):
        repo = UserRepository(db_session)
        repo.create(name="김철수", email="kim@example.com")

        found = repo.find_by_email("kim@example.com")
        assert found is not None
        assert found.name == "김철수"

3.3 API 통합 테스트

// app.integration.test.ts
import request from 'supertest';
import { app } from '../src/app';
import { prisma } from '../src/db';

describe('POST /api/orders', () => {
  beforeEach(async () => {
    await prisma.order.deleteMany();
  });

  it('유효한 요청으로 주문을 생성한다 (201)', async () => {
    const response = await request(app)
      .post('/api/orders')
      .send({
        userId: 'user-123',
        items: [
          { productId: 'prod-1', quantity: 2, price: 15000 },
        ],
      })
      .expect(201);

    expect(response.body).toMatchObject({
      id: expect.any(String),
      status: 'pending',
      total: 30000,
    });
  });

  it('필수 필드가 없으면 400 에러를 반환한다', async () => {
    const response = await request(app)
      .post('/api/orders')
      .send({ userId: 'user-123' })
      .expect(400);

    expect(response.body.error).toContain('items');
  });
});

4. E2E 테스트 (End-to-End Testing)

4.1 Playwright vs Cypress 비교

기능PlaywrightCypress
브라우저 지원Chromium, Firefox, WebKitChromium 기반
언어JS/TS, Python, Java, .NETJS/TS
병렬 실행네이티브 지원유료 (Cypress Cloud)
네트워크 모킹내장 route APIcy.intercept
iframe 지원완전 지원제한적
탭/윈도우다중 지원미지원
실행 속도빠름보통
디버깅Trace Viewer, CodegenTime Travel
커뮤니티빠르게 성장성숙한 생태계

4.2 Playwright 실전 예제

// tests/checkout.spec.ts
import { test, expect } from '@playwright/test';

test.describe('결제 플로우', () => {
  test.beforeEach(async ({ page }) => {
    // 로그인 상태로 시작
    await page.goto('/login');
    await page.fill('[data-testid="email"]', 'test@example.com');
    await page.fill('[data-testid="password"]', 'password123');
    await page.click('[data-testid="login-button"]');
    await page.waitForURL('/dashboard');
  });

  test('상품 추가부터 결제 완료까지', async ({ page }) => {
    // 1. 상품 페이지로 이동
    await page.goto('/products');

    // 2. 상품을 장바구니에 추가
    await page.click('[data-testid="product-card-1"] button');
    await expect(page.locator('[data-testid="cart-count"]'))
      .toHaveText('1');

    // 3. 장바구니로 이동
    await page.click('[data-testid="cart-icon"]');
    await expect(page).toHaveURL('/cart');

    // 4. 수량 변경
    await page.fill('[data-testid="quantity-input"]', '3');
    await expect(page.locator('[data-testid="subtotal"]'))
      .toContainText('45,000');

    // 5. 결제 진행
    await page.click('[data-testid="checkout-button"]');

    // 6. 배송 정보 입력
    await page.fill('#address', '서울시 강남구 테스트로 123');
    await page.fill('#phone', '010-1234-5678');

    // 7. 결제 완료
    await page.click('[data-testid="pay-button"]');
    await expect(page.locator('[data-testid="order-success"]'))
      .toBeVisible();
    await expect(page.locator('[data-testid="order-id"]'))
      .toContainText('ORD-');
  });

  test('재고 부족 시 에러 메시지를 표시한다', async ({ page }) => {
    // API 모킹: 재고 부족 응답
    await page.route('**/api/checkout', (route) => {
      route.fulfill({
        status: 409,
        body: JSON.stringify({
          error: 'OUT_OF_STOCK',
          message: '재고가 부족합니다',
        }),
      });
    });

    await page.goto('/cart');
    await page.click('[data-testid="checkout-button"]');

    await expect(page.locator('[data-testid="error-message"]'))
      .toContainText('재고가 부족합니다');
  });
});

4.3 Page Object Model (POM) 패턴

// pages/LoginPage.ts
import { Page, Locator, expect } from '@playwright/test';

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly loginButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.locator('[data-testid="email"]');
    this.passwordInput = page.locator('[data-testid="password"]');
    this.loginButton = page.locator('[data-testid="login-button"]');
    this.errorMessage = page.locator('[data-testid="error"]');
  }

  async goto() {
    await this.page.goto('/login');
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.loginButton.click();
  }

  async expectError(message: string) {
    await expect(this.errorMessage).toContainText(message);
  }
}

// pages/DashboardPage.ts
export class DashboardPage {
  readonly page: Page;
  readonly welcomeMessage: Locator;
  readonly orderList: Locator;

  constructor(page: Page) {
    this.page = page;
    this.welcomeMessage = page.locator('[data-testid="welcome"]');
    this.orderList = page.locator('[data-testid="order-list"]');
  }

  async expectWelcome(name: string) {
    await expect(this.welcomeMessage).toContainText(name);
  }
}

// tests/login.spec.ts
import { test } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';

test('성공적인 로그인 후 대시보드로 이동', async ({ page }) => {
  const loginPage = new LoginPage(page);
  const dashboard = new DashboardPage(page);

  await loginPage.goto();
  await loginPage.login('user@test.com', 'password123');
  await dashboard.expectWelcome('홍길동');
});

4.4 Visual Regression Testing

// visual.spec.ts
import { test, expect } from '@playwright/test';

test('메인 페이지 시각적 회귀 테스트', async ({ page }) => {
  await page.goto('/');

  // 전체 페이지 스크린샷 비교
  await expect(page).toHaveScreenshot('main-page.png', {
    maxDiffPixelRatio: 0.01,
    threshold: 0.2,
  });
});

test('다크모드 시각적 테스트', async ({ page }) => {
  await page.goto('/');
  await page.click('[data-testid="theme-toggle"]');

  await expect(page).toHaveScreenshot('main-page-dark.png');
});

test('반응형 디자인 테스트', async ({ page }) => {
  const viewports = [
    { width: 375, height: 812, name: 'mobile' },
    { width: 768, height: 1024, name: 'tablet' },
    { width: 1440, height: 900, name: 'desktop' },
  ];

  for (const vp of viewports) {
    await page.setViewportSize({ width: vp.width, height: vp.height });
    await page.goto('/');
    await expect(page).toHaveScreenshot(
      `main-page-${vp.name}.png`
    );
  }
});

5. TDD (Test-Driven Development)

5.1 Red-Green-Refactor 사이클

┌──────────┐    ┌──────────┐    ┌──────────┐
RED    │───▶│  GREEN   │───▶│ REFACTOR│ 실패하는 │    │ 최소한의 │    │ 코드 개선│
│ 테스트   │    │ 구현     │    │ 테스트유지│
└──────────┘    └──────────┘    └─────┬────┘
     ▲                                │
     └────────────────────────────────┘

5.2 TDD 실전 예제: 비밀번호 검증기

// ========== Step 1: RED - 실패하는 테스트 작성 ==========
// passwordValidator.test.ts
describe('PasswordValidator', () => {
  it('8자 미만이면 실패한다', () => {
    expect(validatePassword('Ab1!xyz')).toEqual({
      valid: false,
      errors: ['비밀번호는 최소 8자 이상이어야 합니다'],
    });
  });
});

// ========== Step 2: GREEN - 최소한의 구현 ==========
// passwordValidator.ts
export function validatePassword(password: string) {
  const errors: string[] = [];

  if (password.length < 8) {
    errors.push('비밀번호는 최소 8자 이상이어야 합니다');
  }

  return { valid: errors.length === 0, errors };
}

// ========== Step 3: RED - 다음 테스트 추가 ==========
it('대문자가 없으면 실패한다', () => {
  expect(validatePassword('abcd1234!')).toEqual({
    valid: false,
    errors: ['대문자를 1개 이상 포함해야 합니다'],
  });
});

// ========== Step 4: GREEN - 구현 확장 ==========
export function validatePassword(password: string) {
  const errors: string[] = [];

  if (password.length < 8) {
    errors.push('비밀번호는 최소 8자 이상이어야 합니다');
  }
  if (!/[A-Z]/.test(password)) {
    errors.push('대문자를 1개 이상 포함해야 합니다');
  }

  return { valid: errors.length === 0, errors };
}

// ========== Step 5: 더 많은 규칙 추가 ==========
it('소문자가 없으면 실패한다', () => {
  expect(validatePassword('ABCD1234!')).toEqual({
    valid: false,
    errors: ['소문자를 1개 이상 포함해야 합니다'],
  });
});

it('숫자가 없으면 실패한다', () => {
  expect(validatePassword('Abcdefgh!')).toEqual({
    valid: false,
    errors: ['숫자를 1개 이상 포함해야 합니다'],
  });
});

it('특수문자가 없으면 실패한다', () => {
  expect(validatePassword('Abcdefg1')).toEqual({
    valid: false,
    errors: ['특수문자를 1개 이상 포함해야 합니다'],
  });
});

it('모든 조건을 충족하면 성공한다', () => {
  expect(validatePassword('Abcdefg1!')).toEqual({
    valid: true,
    errors: [],
  });
});

// ========== Step 6: REFACTOR - 코드 정리 ==========
interface ValidationRule {
  test: (password: string) => boolean;
  message: string;
}

const rules: ValidationRule[] = [
  { test: (p) => p.length >= 8, message: '비밀번호는 최소 8자 이상이어야 합니다' },
  { test: (p) => /[A-Z]/.test(p), message: '대문자를 1개 이상 포함해야 합니다' },
  { test: (p) => /[a-z]/.test(p), message: '소문자를 1개 이상 포함해야 합니다' },
  { test: (p) => /\d/.test(p), message: '숫자를 1개 이상 포함해야 합니다' },
  { test: (p) => /[!@#$%^&*]/.test(p), message: '특수문자를 1개 이상 포함해야 합니다' },
];

export function validatePassword(password: string) {
  const errors = rules
    .filter((rule) => !rule.test(password))
    .map((rule) => rule.message);

  return { valid: errors.length === 0, errors };
}

5.3 TDD의 장단점

장점:

  • 설계가 개선됨 (테스트 가능한 코드 = 좋은 설계)
  • 회귀 버그 방지
  • 문서 역할 (테스트가 곧 스펙)
  • 리팩토링에 대한 자신감

단점:

  • 초기 개발 속도 저하
  • 학습 곡선이 있음
  • UI 관련 TDD는 어려움
  • 과도한 테스트 작성 가능성

6. BDD (Behavior-Driven Development)

6.1 Cucumber/Gherkin 구문

# features/order.feature
Feature: 온라인 주문
  사용자로서
  상품을 주문할 수 있어야 한다

  Background:
    Given 로그인한 사용자가 존재한다

  Scenario: 성공적인 주문
    Given 장바구니에 "노트북" 1개가 있다
    And 장바구니에 "마우스" 2개가 있다
    When 주문하기 버튼을 클릭한다
    Then 주문이 성공적으로 생성된다
    And 주문 상태는 "결제 대기"이다
    And 확인 이메일이 발송된다

  Scenario: 재고 부족
    Given 장바구니에 "한정판 키보드" 1개가 있다
    And "한정판 키보드"의 재고가 0개이다
    When 주문하기 버튼을 클릭한다
    Then "재고가 부족합니다" 메시지가 표시된다

  Scenario Outline: 배송비 계산
    Given 주문 총액이 <total>원이다
    When 배송비를 계산한다
    Then 배송비는 <shipping>원이다

    Examples:
      | total  | shipping |
      | 30000  | 3000     |
      | 50000  | 0        |
      | 100000 | 0        |

6.2 Step Definitions (TypeScript)

// steps/order.steps.ts
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';

Given('장바구니에 {string} {int}개가 있다', async function(product, qty) {
  await this.page.goto('/products');
  const card = this.page.locator(`[data-product="${product}"]`);
  await card.locator('[data-testid="add-to-cart"]').click();
  if (qty > 1) {
    await this.page.goto('/cart');
    await this.page.fill(
      `[data-product="${product}"] input[type="number"]`,
      String(qty)
    );
  }
});

When('주문하기 버튼을 클릭한다', async function() {
  await this.page.click('[data-testid="checkout-button"]');
});

Then('주문이 성공적으로 생성된다', async function() {
  await expect(this.page.locator('[data-testid="order-success"]'))
    .toBeVisible();
});

7. 모킹 전략 (Mocking Strategies)

7.1 Mock vs Stub vs Spy vs Fake

┌──────────────────────────────────────────────────────────────┐
Test Doubles 분류                                           │
├──────────┬───────────────────────────────────────────────────┤
Dummy    │ 파라미터 채우기용. 실제로 사용되지 않음            │
Stub     │ 미리 정의된 값을 반환. 입력에 대한 고정 응답       │
Spy      │ 실제 구현 + 호출 기록. 행위 검증 가능             │
Mock     │ 기대하는 호출을 미리 설정. 행위를 검증             │
Fake     │ 작동하는 간단한 구현 (인메모리 DB)└──────────┴───────────────────────────────────────────────────┘

7.2 Jest/Vitest 모킹 패턴

// ========== 함수 모킹 ==========
import { vi, describe, it, expect } from 'vitest';
import { sendEmail } from './emailService';
import { createOrder } from './orderService';

// 모듈 전체 모킹
vi.mock('./emailService', () => ({
  sendEmail: vi.fn().mockResolvedValue({ sent: true }),
}));

describe('createOrder', () => {
  it('주문 생성 후 이메일을 발송한다', async () => {
    const order = await createOrder({ userId: 'user-1', items: [] });

    // 호출 여부 검증 (Spy 패턴)
    expect(sendEmail).toHaveBeenCalledWith(
      expect.objectContaining({
        to: expect.any(String),
        subject: expect.stringContaining('주문'),
      })
    );
  });
});

// ========== API 모킹 (MSW) ==========
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

const server = setupServer(
  http.get('/api/products', () => {
    return HttpResponse.json([
      { id: '1', name: '노트북', price: 1200000 },
      { id: '2', name: '마우스', price: 50000 },
    ]);
  }),

  http.post('/api/orders', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json(
      { id: 'order-123', ...body, status: 'pending' },
      { status: 201 }
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

7.3 언제 모킹해야 하는가?

모킹해야 할 때모킹하지 말아야 할 때
외부 API 호출순수 함수/비즈니스 로직
데이터베이스 (단위 테스트)값 객체 (Value Objects)
파일 시스템테스트 대상 자체
시간/날짜 관련단순한 헬퍼 함수
이메일/SMS 발송가까운 의존성
결제 게이트웨이데이터 변환 로직

모킹 황금률: "자기 소유가 아닌 것만 모킹하라 (Don't mock what you don't own)"


8. 테스트 커버리지

8.1 커버리지 유형

┌─────────────────────────────────────────────────────┐
Coverage Types├──────────────┬──────────────────────────────────────┤
Line         │ 실행된 라인 비율                      │
Branch조건문(if/else)의 분기 커버 비율       │
Function     │ 호출된 함수 비율                      │
Statement    │ 실행된 구문 비율                      │
└──────────────┴──────────────────────────────────────┘

8.2 Mutation Testing

전통적 커버리지의 한계를 보완하는 변이 테스트입니다.

// 원본 코드
function isAdult(age: number): boolean {
  return age >= 18;
}

// 변이 1: 경계값 변경
function isAdult(age: number): boolean {
  return age > 18;  // >= 를 > 로 변경
}

// 변이 2: 연산자 변경
function isAdult(age: number): boolean {
  return age <= 18;  // >= 를 <= 로 변경
}

// 테스트가 이 변이를 잡아내지 못하면 = 테스트가 약하다
# Stryker (JavaScript/TypeScript)
npx stryker run

# 결과 예시
# Mutation score: 85%
# 생존한 변이: 12개
# 죽은 변이: 68개
# 타임아웃: 3개

8.3 합리적인 커버리지 목표

프로젝트 유형LineBranch비고
핵심 비즈니스 로직90%+85%+금융, 결제 관련
일반 백엔드 API80%+75%+CRUD 포함
프론트엔드 UI70%+65%+스타일 제외
유틸리티 라이브러리95%+90%+재사용 코드

커버리지 함정을 피하라: 100% 커버리지가 버그 없음을 보장하지 않습니다. 의미 있는 assertion이 더 중요합니다.


9. 컴포넌트 테스트 (React)

9.1 React Testing Library

// components/SearchBar.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchBar } from './SearchBar';

describe('SearchBar', () => {
  it('검색어 입력 후 결과를 표시한다', async () => {
    const user = userEvent.setup();
    const onSearch = vi.fn().mockResolvedValue([
      { id: 1, title: 'React 입문' },
      { id: 2, title: 'React 심화' },
    ]);

    render(<SearchBar onSearch={onSearch} />);

    // 검색어 입력
    const input = screen.getByPlaceholderText('검색어를 입력하세요');
    await user.type(input, 'React');

    // 검색 버튼 클릭
    await user.click(screen.getByRole('button', { name: '검색' }));

    // 결과 확인
    await waitFor(() => {
      expect(screen.getByText('React 입문')).toBeInTheDocument();
      expect(screen.getByText('React 심화')).toBeInTheDocument();
    });

    expect(onSearch).toHaveBeenCalledWith('React');
  });

  it('빈 검색어로 검색 시 에러를 표시한다', async () => {
    const user = userEvent.setup();
    render(<SearchBar onSearch={vi.fn()} />);

    await user.click(screen.getByRole('button', { name: '검색' }));

    expect(screen.getByText('검색어를 입력해주세요'))
      .toBeInTheDocument();
  });
});

9.2 스냅샷 테스트

// components/OrderCard.test.tsx
import { render } from '@testing-library/react';
import { OrderCard } from './OrderCard';

describe('OrderCard', () => {
  it('스냅샷과 일치한다', () => {
    const { container } = render(
      <OrderCard
        order={{
          id: 'ORD-001',
          status: 'completed',
          total: 50000,
          items: [
            { name: '키보드', quantity: 1, price: 50000 },
          ],
        }}
      />
    );

    expect(container).toMatchSnapshot();
  });
});

10. 성능 테스트

10.1 k6 부하 테스트

// load-test.js
import http from 'k6/http';
import { check, sleep } from 'k6';
import { Rate, Trend } from 'k6/metrics';

const errorRate = new Rate('errors');
const orderDuration = new Trend('order_duration');

export const options = {
  stages: [
    { duration: '2m', target: 50 },   // 워밍업
    { duration: '5m', target: 200 },  // 부하 증가
    { duration: '3m', target: 500 },  // 피크 부하
    { duration: '2m', target: 0 },    // 쿨다운
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],  // 95%가 500ms 이내
    errors: ['rate<0.01'],              // 에러율 1% 미만
    order_duration: ['p(99)<1000'],    // 주문 99%가 1초 이내
  },
};

export default function () {
  // 상품 목록 조회
  const productsRes = http.get('http://localhost:3000/api/products');
  check(productsRes, {
    '상품 목록 200': (r) => r.status === 200,
    '응답 시간 200ms 이내': (r) => r.timings.duration < 200,
  });

  // 주문 생성
  const orderStart = Date.now();
  const orderRes = http.post(
    'http://localhost:3000/api/orders',
    JSON.stringify({
      userId: `user-${__VU}`,
      items: [{ productId: 'prod-1', quantity: 1 }],
    }),
    { headers: { 'Content-Type': 'application/json' } }
  );

  orderDuration.add(Date.now() - orderStart);
  errorRate.add(orderRes.status !== 201);

  check(orderRes, {
    '주문 생성 201': (r) => r.status === 201,
  });

  sleep(1);
}

10.2 Artillery 설정

# artillery.yml
config:
  target: "http://localhost:3000"
  phases:
    - duration: 120
      arrivalRate: 10
      name: "Warm up"
    - duration: 300
      arrivalRate: 50
      name: "Sustained load"
    - duration: 60
      arrivalRate: 100
      name: "Spike test"
  plugins:
    ensure:
      thresholds:
        - http.response_time.p99: 1000
        - http.codes.200: { min: 95 }

scenarios:
  - name: "Browse and Order"
    flow:
      - get:
          url: "/api/products"
      - think: 2
      - post:
          url: "/api/orders"
          json:
            userId: "user-{{ $randomString() }}"
            items:
              - productId: "prod-1"
                quantity: 1
      - get:
          url: "/api/orders/{{ $json.id }}"

11. Contract Testing (계약 테스트)

11.1 Pact를 이용한 Consumer-Driven Contract

// consumer.pact.test.ts (프론트엔드 - Consumer)
import { PactV3 } from '@pact-foundation/pact';

const provider = new PactV3({
  consumer: 'frontend-app',
  provider: 'order-api',
});

describe('Order API Contract', () => {
  it('주문 목록을 조회한다', async () => {
    // 기대하는 응답 정의
    provider
      .given('주문이 존재하는 경우')
      .uponReceiving('주문 목록 요청')
      .withRequest({
        method: 'GET',
        path: '/api/orders',
        headers: { Accept: 'application/json' },
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: [
          {
            id: 'order-1',
            status: 'pending',
            total: 50000,
            createdAt: '2025-01-15T10:00:00Z',
          },
        ],
      });

    await provider.executeTest(async (mockService) => {
      const response = await fetch(
        `${mockService.url}/api/orders`,
        { headers: { Accept: 'application/json' } }
      );
      const orders = await response.json();

      expect(orders).toHaveLength(1);
      expect(orders[0].id).toBe('order-1');
    });
  });
});

12. CI 통합 전략

12.1 GitHub Actions 테스트 파이프라인

# .github/workflows/test.yml
name: Test Pipeline

on:
  pull_request:
    branches: [main, develop]
  push:
    branches: [main]

jobs:
  unit-tests:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      - run: npm ci
      - run: npm run test:unit -- --coverage
      - uses: actions/upload-artifact@v4
        with:
          name: coverage-node-${{ matrix.node-version }}
          path: coverage/

  integration-tests:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7
        ports:
          - 6379:6379
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - run: npm run test:integration
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379

  e2e-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - name: Install Playwright
        run: npx playwright install --with-deps
      - name: Run E2E Tests
        run: npx playwright test --shard=${{ strategy.job-index+1 }}/${{ strategy.job-total }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

  flaky-test-detection:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'
      - run: npm ci
      - name: Run flaky detection
        run: |
          for i in $(seq 1 5); do
            npm run test:unit 2>&1 | tee -a test-results.log
          done
      - name: Analyze results
        run: node scripts/analyze-flaky-tests.js

12.2 테스트 분할 (Parallel Execution)

# Playwright 샤딩 예제
e2e-tests:
  runs-on: ubuntu-latest
  strategy:
    matrix:
      shard: [1, 2, 3, 4]
  steps:
    - uses: actions/checkout@v4
    - run: npm ci
    - run: npx playwright install --with-deps
    - run: npx playwright test --shard=${{ matrix.shard }}/4

13. 실전 퀴즈

Q1: 다음 중 테스트 피라미드의 올바른 비율은?

A) E2E 50%, Integration 30%, Unit 20% B) Unit 70%, Integration 20%, E2E 10% C) Unit 33%, Integration 33%, E2E 33% D) E2E 70%, Integration 20%, Unit 10%

정답: B

테스트 피라미드는 단위 테스트가 가장 많고, E2E 테스트가 가장 적은 구조입니다. 단위 테스트는 빠르고 유지보수 비용이 낮으며, E2E 테스트는 느리고 비용이 높기 때문입니다.

Q2: TDD의 Red-Green-Refactor 사이클에서 올바른 순서는?

A) 코드 작성 - 테스트 작성 - 리팩토링 B) 테스트 작성(실패) - 최소 구현(성공) - 리팩토링 C) 리팩토링 - 테스트 작성 - 코드 작성 D) 최소 구현 - 테스트 작성 - 리팩토링

정답: B

TDD는 먼저 실패하는 테스트를 작성하고(Red), 테스트를 통과하는 최소한의 코드를 작성한 후(Green), 코드를 개선합니다(Refactor). 이 사이클을 반복합니다.

Q3: Mock과 Stub의 가장 큰 차이점은?

A) Mock은 빠르고 Stub은 느리다 B) Mock은 행위(호출)를 검증하고 Stub은 상태(반환값)를 검증한다 C) Mock은 프론트엔드용이고 Stub은 백엔드용이다 D) 차이가 없다

정답: B

Stub은 미리 정의된 값을 반환하는 데 초점을 맞추고(상태 검증), Mock은 특정 메서드가 호출되었는지, 어떤 인자로 호출되었는지를 검증합니다(행위 검증).

Q4: Playwright와 Cypress의 가장 큰 차이점은?

A) Playwright는 유료이고 Cypress는 무료이다 B) Playwright는 다중 브라우저/탭을 지원하고 Cypress는 단일 탭만 지원한다 C) Cypress가 더 빠르다 D) Playwright는 JavaScript만 지원한다

정답: B

Playwright는 Chromium, Firefox, WebKit을 지원하고 다중 탭/윈도우를 테스트할 수 있습니다. Cypress는 Chromium 기반 브라우저에서 단일 탭으로 동작합니다. Playwright는 JS/TS, Python, Java, .NET을 지원합니다.

Q5: Mutation Testing(변이 테스트)의 목적은?

A) 코드의 성능을 측정한다 B) 테스트 코드의 품질(강도)을 평가한다 C) 보안 취약점을 찾는다 D) 코드 스타일을 검사한다

정답: B

Mutation Testing은 소스 코드에 작은 변이(연산자 변경, 조건 반전 등)를 만들고, 기존 테스트가 이 변이를 감지하는지 확인합니다. 변이를 잡아내지 못하면 테스트가 약하다는 의미입니다.


14. 테스트 전략 체크리스트

프로젝트 테스트 전략 체크리스트:

[  ] 1. 테스트 모델 선택 (피라미드/트로피/다이아몬드)
[  ] 2. 테스트 프레임워크 선택 (Jest/Vitest/Pytest/JUnit)
[  ] 3. 단위 테스트 - 비즈니스 로직 커버
[  ] 4. 통합 테스트 - DB/API 연동 검증
[  ] 5. E2E 테스트 - 핵심 사용자 플로우
[  ] 6. 모킹 전략 수립
[  ] 7. CI 파이프라인 통합
[  ] 8. 커버리지 목표 설정
[  ] 9. Flaky 테스트 모니터링
[  ] 10. 성능 테스트 (k6/Artillery)

15. 참고 자료

  1. Testing JavaScript - Kent C. Dodds: testingjavascript.com
  2. Playwright Documentation: playwright.dev
  3. Vitest Documentation: vitest.dev
  4. Pytest Documentation: docs.pytest.org
  5. JUnit 5 User Guide: junit.org/junit5
  6. Martin Fowler - Test Pyramid: martinfowler.com/bliki/TestPyramid.html
  7. Testcontainers: testcontainers.com
  8. MSW (Mock Service Worker): mswjs.io
  9. k6 Load Testing: k6.io
  10. Pact Contract Testing: pact.io
  11. Stryker Mutator: stryker-mutator.io
  12. Google Testing Blog: testing.googleblog.com
  13. Cypress Documentation: docs.cypress.io
  14. React Testing Library: testing-library.com/react