Skip to content

Split View: 소프트웨어 테스트 완전 가이드 2025: TDD, 단위/통합/E2E 테스트, CI/CD 자동화까지

✨ Learn with Quiz
|

소프트웨어 테스트 완전 가이드 2025: TDD, 단위/통합/E2E 테스트, CI/CD 자동화까지

1. 왜 테스트가 중요한가?

프로덕션에서 발견되는 버그의 수정 비용은 개발 단계에서 발견할 때보다 100배 이상 높습니다 (IBM Systems Sciences Institute 연구). 테스트는 단순한 버그 찾기가 아니라 소프트웨어 품질 보증, 리팩토링 안전망, 문서 역할을 합니다.

테스트의 핵심 가치

  • 자신감 있는 배포: 테스트가 통과하면 안심하고 배포할 수 있습니다
  • 리팩토링 자유: 기존 동작을 보장하면서 코드를 개선할 수 있습니다
  • 살아있는 문서: 테스트 코드가 기능의 사용법을 보여줍니다
  • 설계 개선: 테스트하기 어려운 코드는 설계가 잘못된 신호입니다
  • 회귀 방지: 새 기능 추가 시 기존 기능이 깨지지 않음을 보장합니다

2. 테스트 피라미드

Martin Fowler가 제안한 테스트 피라미드는 테스트 전략의 핵심 프레임워크입니다.

        /\
       /  \        E2E Tests (적게)
      /    \       - 느리고 비용이 높음
     /------\      - 전체 시스템 검증
    /        \
   / Integration\  Integration Tests (적당히)
  /    Tests     \ - 컴포넌트 간 상호작용
 /--------------\ - DB, API 연동 테스트
/                \
/   Unit Tests    \ Unit Tests (많이)
/------------------\ - 빠르고 안정적
                     - 개별 함수/클래스 검증
테스트 유형비율실행 속도유지보수 비용신뢰도
Unit70%매우 빠름낮음중간
Integration20%보통중간높음
E2E10%느림높음매우 높음

3. TDD (Test-Driven Development)

3.1 Red-Green-Refactor 사이클

TDD는 세 단계의 반복 사이클로 이루어집니다.

  1. Red: 실패하는 테스트를 먼저 작성
  2. Green: 테스트를 통과하는 최소한의 코드 작성
  3. Refactor: 코드를 정리하면서 테스트가 계속 통과하는지 확인

3.2 TDD 실전 예제 (TypeScript + Jest)

사용자 이메일 검증 함수를 TDD로 개발하는 과정입니다.

Step 1: Red - 실패하는 테스트 작성

// email-validator.test.ts
import { validateEmail } from './email-validator';

describe('validateEmail', () => {
  it('should return true for valid email', () => {
    expect(validateEmail('user@example.com')).toBe(true);
  });

  it('should return false for email without @', () => {
    expect(validateEmail('userexample.com')).toBe(false);
  });

  it('should return false for email without domain', () => {
    expect(validateEmail('user@')).toBe(false);
  });

  it('should return false for empty string', () => {
    expect(validateEmail('')).toBe(false);
  });

  it('should return false for email with spaces', () => {
    expect(validateEmail('user @example.com')).toBe(false);
  });
});

Step 2: Green - 최소한의 구현

// email-validator.ts
export function validateEmail(email: string): boolean {
  if (!email || email.includes(' ')) return false;
  const parts = email.split('@');
  if (parts.length !== 2) return false;
  const [local, domain] = parts;
  return local.length > 0 && domain.includes('.');
}

Step 3: Refactor - 정규식으로 개선

// email-validator.ts (리팩토링)
export function validateEmail(email: string): boolean {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}

3.3 TDD 실전 예제 (Python + pytest)

# test_calculator.py
import pytest
from calculator import Calculator

class TestCalculator:
    def setup_method(self):
        self.calc = Calculator()

    def test_add(self):
        assert self.calc.add(2, 3) == 5

    def test_add_negative(self):
        assert self.calc.add(-1, -2) == -3

    def test_divide(self):
        assert self.calc.divide(10, 2) == 5.0

    def test_divide_by_zero(self):
        with pytest.raises(ValueError, match="Cannot divide by zero"):
            self.calc.divide(10, 0)

    def test_divide_returns_float(self):
        assert isinstance(self.calc.divide(7, 2), float)
# calculator.py
class Calculator:
    def add(self, a: int, b: int) -> int:
        return a + b

    def divide(self, a: int, b: int) -> float:
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return float(a) / b

4. 단위 테스트 (Unit Testing)

4.1 Jest (TypeScript/JavaScript)

// user-service.test.ts
import { UserService } from './user-service';
import { UserRepository } from './user-repository';

// Mock 생성
jest.mock('./user-repository');
const MockedUserRepo = UserRepository as jest.MockedClass<typeof UserRepository>;

describe('UserService', () => {
  let service: UserService;
  let mockRepo: jest.Mocked<UserRepository>;

  beforeEach(() => {
    mockRepo = new MockedUserRepo() as jest.Mocked<UserRepository>;
    service = new UserService(mockRepo);
    jest.clearAllMocks();
  });

  describe('getUser', () => {
    it('should return user when found', async () => {
      const mockUser = { id: '1', name: 'Alice', email: 'alice@test.com' };
      mockRepo.findById.mockResolvedValue(mockUser);

      const result = await service.getUser('1');

      expect(result).toEqual(mockUser);
      expect(mockRepo.findById).toHaveBeenCalledWith('1');
      expect(mockRepo.findById).toHaveBeenCalledTimes(1);
    });

    it('should throw when user not found', async () => {
      mockRepo.findById.mockResolvedValue(null);

      await expect(service.getUser('999')).rejects.toThrow('User not found');
    });
  });

  describe('createUser', () => {
    it('should create user with hashed password', async () => {
      const input = { name: 'Bob', email: 'bob@test.com', password: 'secret' };
      mockRepo.create.mockResolvedValue({ id: '2', ...input, password: 'hashed' });

      const result = await service.createUser(input);

      expect(result.id).toBe('2');
      expect(mockRepo.create).toHaveBeenCalledTimes(1);
    });

    it('should throw for duplicate email', async () => {
      mockRepo.findByEmail.mockResolvedValue({ id: '1', email: 'bob@test.com' } as any);

      await expect(
        service.createUser({ name: 'Bob', email: 'bob@test.com', password: 'x' })
      ).rejects.toThrow('Email already exists');
    });
  });
});

4.2 pytest (Python)

# test_order_service.py
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from order_service import OrderService
from datetime import datetime

class TestOrderService:
    def setup_method(self):
        self.mock_repo = MagicMock()
        self.mock_payment = MagicMock()
        self.service = OrderService(
            repository=self.mock_repo,
            payment_gateway=self.mock_payment
        )

    def test_create_order_success(self):
        self.mock_payment.charge.return_value = {"status": "success", "tx_id": "tx123"}
        self.mock_repo.save.return_value = {"id": "order1", "total": 100}

        result = self.service.create_order(
            user_id="user1",
            items=[{"product_id": "p1", "quantity": 2, "price": 50}]
        )

        assert result["id"] == "order1"
        self.mock_payment.charge.assert_called_once_with(amount=100, user_id="user1")
        self.mock_repo.save.assert_called_once()

    def test_create_order_payment_failure(self):
        self.mock_payment.charge.side_effect = Exception("Payment declined")

        with pytest.raises(Exception, match="Payment declined"):
            self.service.create_order(
                user_id="user1",
                items=[{"product_id": "p1", "quantity": 1, "price": 50}]
            )

        self.mock_repo.save.assert_not_called()

    @pytest.mark.parametrize("items,expected_total", [
        ([{"price": 10, "quantity": 1}], 10),
        ([{"price": 10, "quantity": 3}], 30),
        ([{"price": 10, "quantity": 2}, {"price": 20, "quantity": 1}], 40),
    ])
    def test_calculate_total(self, items, expected_total):
        total = self.service.calculate_total(items)
        assert total == expected_total

4.3 JUnit 5 (Java)

// UserServiceTest.java
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private PasswordEncoder passwordEncoder;

    @InjectMocks
    private UserService userService;

    @Test
    @DisplayName("Should return user when valid ID is provided")
    void getUser_ValidId_ReturnsUser() {
        User expected = new User("1", "Alice", "alice@test.com");
        when(userRepository.findById("1")).thenReturn(Optional.of(expected));

        User result = userService.getUser("1");

        assertThat(result.getName()).isEqualTo("Alice");
        verify(userRepository).findById("1");
    }

    @Test
    @DisplayName("Should throw exception when user not found")
    void getUser_InvalidId_ThrowsException() {
        when(userRepository.findById("999")).thenReturn(Optional.empty());

        assertThatThrownBy(() -> userService.getUser("999"))
            .isInstanceOf(UserNotFoundException.class)
            .hasMessage("User not found: 999");
    }

    @Test
    @DisplayName("Should create user with encoded password")
    void createUser_ValidInput_CreatesUserWithEncodedPassword() {
        when(passwordEncoder.encode("rawPassword")).thenReturn("encodedPassword");
        when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));

        User result = userService.createUser("Bob", "bob@test.com", "rawPassword");

        assertThat(result.getPassword()).isEqualTo("encodedPassword");
        verify(passwordEncoder).encode("rawPassword");
    }
}

5. 모킹 (Mocking) 전략

5.1 Mock vs Stub vs Spy

유형용도검증
Mock호출 여부와 인자 검증행위 검증 (verify)
Stub미리 정해진 값 반환상태 검증 (assert)
Spy실제 구현 호출 + 기록행위 + 상태 검증

5.2 Jest 모킹 패턴

// 모듈 모킹
jest.mock('./api-client', () => ({
  fetchData: jest.fn().mockResolvedValue({ data: 'mocked' }),
}));

// 타이머 모킹
jest.useFakeTimers();
setTimeout(() => callback(), 1000);
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();

// fetch 모킹
global.fetch = jest.fn().mockResolvedValue({
  ok: true,
  json: () => Promise.resolve({ users: [] }),
});

5.3 Python unittest.mock 패턴

from unittest.mock import patch, MagicMock
from datetime import datetime

# 데코레이터 패턴
@patch('module.datetime')
def test_greeting_morning(mock_datetime):
    mock_datetime.now.return_value = datetime(2025, 1, 1, 9, 0, 0)
    assert get_greeting() == "Good morning"

# 컨텍스트 매니저 패턴
def test_api_call():
    with patch('requests.get') as mock_get:
        mock_get.return_value.status_code = 200
        mock_get.return_value.json.return_value = {"status": "ok"}

        result = fetch_status()
        assert result == "ok"
        mock_get.assert_called_once()

5.4 언제 모킹을 사용하나?

모킹이 적절한 경우:

  • 외부 API 호출 (HTTP, gRPC)
  • 데이터베이스 접근
  • 파일 시스템 I/O
  • 시간 의존 로직 (현재 시각, 타이머)
  • 랜덤 값 생성

모킹을 피해야 하는 경우:

  • 순수 함수 (입력만으로 결과가 결정)
  • 값 객체 (DTO, Entity)
  • 테스트 대상 자체의 내부 메서드

6. 통합 테스트 (Integration Testing)

6.1 Testcontainers 활용

Testcontainers는 Docker 컨테이너로 실제 데이터베이스를 띄워 통합 테스트를 수행합니다.

// user-repository.integration.test.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
import { UserRepository } from './user-repository';

describe('UserRepository Integration', () => {
  let container: any;
  let pool: Pool;
  let repo: UserRepository;

  beforeAll(async () => {
    container = await new PostgreSqlContainer()
      .withDatabase('testdb')
      .start();

    pool = new Pool({ connectionString: container.getConnectionUri() });
    await pool.query(`
      CREATE TABLE users (
        id SERIAL PRIMARY KEY,
        name VARCHAR(100),
        email VARCHAR(100) UNIQUE
      )
    `);
    repo = new UserRepository(pool);
  }, 60000);

  afterAll(async () => {
    await pool.end();
    await container.stop();
  });

  afterEach(async () => {
    await pool.query('DELETE FROM users');
  });

  it('should save and retrieve user', async () => {
    await repo.create({ name: 'Alice', email: 'alice@test.com' });

    const user = await repo.findByEmail('alice@test.com');

    expect(user).toBeDefined();
    expect(user!.name).toBe('Alice');
  });

  it('should throw on duplicate email', async () => {
    await repo.create({ name: 'Alice', email: 'alice@test.com' });

    await expect(
      repo.create({ name: 'Bob', email: 'alice@test.com' })
    ).rejects.toThrow();
  });
});

6.2 API 통합 테스트 (supertest)

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

describe('POST /api/users', () => {
  it('should create user and return 201', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@test.com' })
      .expect(201);

    expect(response.body).toMatchObject({
      name: 'Alice',
      email: 'alice@test.com',
    });
    expect(response.body.id).toBeDefined();
  });

  it('should return 400 for invalid email', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'invalid' })
      .expect(400);

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

  it('should return 409 for duplicate email', async () => {
    await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@test.com' });

    await request(app)
      .post('/api/users')
      .send({ name: 'Bob', email: 'alice@test.com' })
      .expect(409);
  });
});

7. E2E 테스트

7.1 Playwright vs Cypress 비교

기능PlaywrightCypress
브라우저 지원Chromium, Firefox, WebKitChrome, Firefox, Edge
언어TS/JS, Python, Java, C#JS/TS
병렬 실행네이티브 지원유료 (Cypress Cloud)
네트워크 요청강력한 가로채기가로채기 지원
모바일디바이스 에뮬레이션뷰포트만
속도매우 빠름빠름
디버깅Trace ViewerTime Travel
커뮤니티빠르게 성장대규모

7.2 Playwright 예제

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

test.describe('Login Flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('should login with valid credentials', async ({ page }) => {
    await page.fill('[data-testid="email-input"]', 'user@test.com');
    await page.fill('[data-testid="password-input"]', 'password123');
    await page.click('[data-testid="login-button"]');

    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('[data-testid="welcome-message"]')).toContainText('Welcome');
  });

  test('should show error for invalid credentials', async ({ page }) => {
    await page.fill('[data-testid="email-input"]', 'wrong@test.com');
    await page.fill('[data-testid="password-input"]', 'wrong');
    await page.click('[data-testid="login-button"]');

    await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
    await expect(page).toHaveURL('/login');
  });

  test('should navigate to registration page', async ({ page }) => {
    await page.click('text=Sign up');
    await expect(page).toHaveURL('/register');
  });
});

7.3 Page Object Model (POM)

// pages/login-page.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-input"]');
    this.passwordInput = page.locator('[data-testid="password-input"]');
    this.loginButton = page.locator('[data-testid="login-button"]');
    this.errorMessage = page.locator('[data-testid="error-message"]');
  }

  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);
  }
}

// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';

test('should login successfully', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@test.com', 'password123');
  await expect(page).toHaveURL('/dashboard');
});

7.4 Cypress 예제

// cypress/e2e/checkout.cy.js
describe('Checkout Flow', () => {
  beforeEach(() => {
    cy.login('user@test.com', 'password');
    cy.visit('/products');
  });

  it('should complete checkout process', () => {
    // 상품 추가
    cy.get('[data-testid="product-card"]').first().click();
    cy.get('[data-testid="add-to-cart"]').click();
    cy.get('[data-testid="cart-count"]').should('contain', '1');

    // 장바구니로 이동
    cy.get('[data-testid="cart-icon"]').click();
    cy.url().should('include', '/cart');

    // 결제 진행
    cy.get('[data-testid="checkout-button"]').click();
    cy.get('[data-testid="shipping-form"]').within(() => {
      cy.get('input[name="address"]').type('123 Main St');
      cy.get('input[name="city"]').type('Seoul');
    });

    cy.get('[data-testid="confirm-order"]').click();
    cy.get('[data-testid="order-confirmation"]').should('be.visible');
    cy.get('[data-testid="order-number"]').should('exist');
  });

  it('should handle empty cart', () => {
    cy.visit('/cart');
    cy.get('[data-testid="empty-cart-message"]').should('be.visible');
    cy.get('[data-testid="checkout-button"]').should('be.disabled');
  });
});

8. 성능 테스트 (Performance Testing)

8.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 responseTime = new Trend('response_time');

export const options = {
  stages: [
    { duration: '1m', target: 50 },    // Ramp up to 50 users
    { duration: '3m', target: 50 },    // Stay at 50 users
    { duration: '1m', target: 200 },   // Ramp up to 200 users
    { duration: '3m', target: 200 },   // Stay at 200 users
    { duration: '1m', target: 0 },     // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],   // 95% of requests under 500ms
    errors: ['rate<0.01'],              // Error rate under 1%
  },
};

export default function () {
  const res = http.get('https://api.example.com/products');

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
    'body has products': (r) => JSON.parse(r.body).length > 0,
  });

  errorRate.add(res.status !== 200);
  responseTime.add(res.timings.duration);

  sleep(1);
}

실행 명령어:

k6 run load-test.js
k6 run --out json=results.json load-test.js    # JSON 결과 출력
k6 run --vus 100 --duration 30s load-test.js   # 빠른 스모크 테스트

9. 코드 커버리지

9.1 커버리지 유형

유형설명중요도
Line Coverage실행된 코드 줄 비율기본
Branch Coverage분기(if/else) 실행 비율높음
Function Coverage호출된 함수 비율중간
Statement Coverage실행된 문장 비율기본

9.2 커버리지 도구 설정

Jest (Istanbul):

{
  "jest": {
    "collectCoverage": true,
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    },
    "coveragePathIgnorePatterns": [
      "/node_modules/",
      "/test/",
      "/__mocks__/"
    ]
  }
}

pytest (coverage.py):

# pyproject.toml
[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=html --cov-report=term-missing --cov-fail-under=80"

[tool.coverage.run]
omit = ["tests/*", "*/migrations/*"]

JaCoCo (Java/Gradle):

// build.gradle
plugins {
    id 'jacoco'
}

jacocoTestReport {
    reports {
        xml.required = true
        html.required = true
    }
}

jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                minimum = 0.80
            }
        }
    }
}

9.3 100% 커버리지의 함정

100% 커버리지를 목표로 하지 마세요. 이유:

  • 의미 없는 테스트 양산: getter/setter, 단순 위임 메서드를 위한 테스트는 가치가 낮습니다
  • 유지보수 부담 증가: 구현 변경 시 깨지는 테스트가 많아집니다
  • 거짓 안전감: 코드를 실행하는 것과 올바름을 검증하는 것은 다릅니다
  • 추천 목표: 핵심 비즈니스 로직 90%+, 전체 프로젝트 70-85%

10. CI/CD 테스트 자동화

10.1 GitHub Actions 워크플로

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

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

jobs:
  unit-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - run: npm ci
      - run: npm run lint
      - run: npm run test:unit -- --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  integration-test:
    runs-on: ubuntu-latest
    needs: unit-test
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    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://postgres:testpass@localhost:5432/testdb

  e2e-test:
    runs-on: ubuntu-latest
    needs: integration-test
    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
      - run: npm run test:e2e
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

11. 뮤테이션 테스트 (Mutation Testing)

뮤테이션 테스트는 테스트의 품질을 검증합니다. 소스 코드를 약간 변경(뮤턴트)하고, 테스트가 그 변경을 감지하는지 확인합니다.

11.1 Stryker (JavaScript/TypeScript)

{
  "stryker": {
    "mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
    "testRunner": "jest",
    "reporters": ["html", "clear-text", "progress"],
    "thresholds": {
      "high": 80,
      "low": 60,
      "break": 50
    }
  }
}
npx stryker run

변경 유형 예시:

  • 조건 반전: if (a > b)if (a <= b) 로 변경
  • 연산자 변경: a + ba - b 로 변경
  • 반환값 변경: return truereturn false 로 변경

뮤턴트가 살아남으면(테스트가 통과하면) 해당 부분의 테스트가 불충분하다는 의미입니다.


12. 계약 테스트 (Contract Testing)

12.1 Pact 기본 개념

마이크로서비스 간의 API 계약을 검증합니다. Consumer가 기대하는 응답과 Provider가 실제로 보내는 응답이 일치하는지 확인합니다.

// consumer.test.ts (Consumer 측)
import { PactV3 } from '@pact-foundation/pact';

const provider = new PactV3({
  consumer: 'OrderService',
  provider: 'UserService',
});

describe('User API Contract', () => {
  it('should return user details', async () => {
    await provider
      .given('user with ID 1 exists')
      .uponReceiving('a request for user 1')
      .withRequest({
        method: 'GET',
        path: '/api/users/1',
      })
      .willRespondWith({
        status: 200,
        body: {
          id: '1',
          name: 'Alice',
          email: 'alice@example.com',
        },
      })
      .executeTest(async (mockserver) => {
        const response = await fetch(`${mockserver.url}/api/users/1`);
        const data = await response.json();
        expect(data.name).toBe('Alice');
      });
  });
});

13. AI 기반 테스트

AI가 테스트 작성을 어떻게 도울 수 있는지 살펴봅니다.

13.1 AI 테스트 도구 활용

  • GitHub Copilot: 테스트 함수 시그니처를 작성하면 테스트 본문을 자동 완성
  • ChatGPT/Claude: 함수를 주면 엣지 케이스를 포함한 테스트 코드 생성
  • Codium AI: 코드 분석 후 의미 있는 테스트 자동 생성

13.2 AI 테스트의 한계

  • 비즈니스 로직의 의도를 완전히 이해하지 못할 수 있음
  • 생성된 테스트가 구현 세부사항에 결합될 수 있음
  • 엣지 케이스의 중요도 판단이 부정확할 수 있음
  • 결론: AI는 보조 도구이며, 테스트 설계는 개발자의 영역

14. 테스트 안티패턴

14.1 피해야 할 패턴 10가지

  1. Ice Cream Cone: E2E가 많고 Unit이 적은 역 피라미드. 느리고 불안정합니다.

  2. 구현 세부사항 테스트: 내부 메서드를 직접 테스트. 리팩토링하면 깨집니다.

  3. 절대 실패하지 않는 테스트: assertion이 없거나 항상 통과하는 테스트.

  4. 상호 의존적 테스트: 실행 순서에 따라 결과가 달라지는 테스트.

  5. 느린 테스트: 전체 테스트 스위트가 10분 이상. 개발자가 실행을 기피하게 됩니다.

  6. Flaky Test: 같은 코드에서 때로는 성공, 때로는 실패. 타이밍, 외부 의존성이 원인입니다.

  7. 과도한 모킹: 모킹이 너무 많으면 실제 동작과 괴리가 생깁니다.

  8. 매직 넘버: 테스트에서 의미 불명의 하드코딩 값. 가독성이 떨어집니다.

  9. 테스트 중복: 같은 로직을 여러 테스트에서 반복. DRY 원칙을 적용하세요.

  10. 주석 처리된 테스트: 실패하는 테스트를 주석으로 처리. 삭제하거나 수정하세요.

14.2 Flaky Test 대처법

// Bad: 타이밍에 의존하는 테스트
test('should show toast after action', async () => {
  await page.click('#submit');
  await page.waitForTimeout(2000);  // 나쁨: 하드코딩된 대기 시간
  expect(await page.isVisible('.toast')).toBe(true);
});

// Good: 조건 기반 대기
test('should show toast after action', async () => {
  await page.click('#submit');
  await expect(page.locator('.toast')).toBeVisible({ timeout: 5000 });
});

15. 면접 질문 15선

Q1. 테스트 피라미드에서 각 레벨의 특징과 적절한 비율은?

Unit Test (70%): 개별 함수/클래스를 격리하여 테스트. 매우 빠르고 안정적. 외부 의존성은 모킹 처리.

Integration Test (20%): 컴포넌트 간 상호작용을 테스트. 실제 DB, API 연동 검증. Unit보다 느리지만 실제 환경에 가까움.

E2E Test (10%): 사용자 시나리오 전체를 테스트. 브라우저에서 실행. 가장 느리고 불안정하지만 가장 높은 신뢰도.

Q2. TDD의 장단점은?

장점: 높은 테스트 커버리지, 더 나은 설계(테스트 가능한 코드), 빠른 피드백, 리팩토링 자신감, 디버깅 시간 단축.

단점: 초기 개발 속도 감소, 학습 곡선, 요구사항이 자주 변하는 프로토타입에 부적합, 레거시 코드에 적용 어려움.

Q3. Mock과 Stub의 차이점은?

Mock: 행위를 검증합니다. 메서드가 특정 인자로 호출되었는지, 몇 번 호출되었는지를 확인합니다. (verify)

Stub: 미리 정해진 값을 반환합니다. 테스트에서 외부 의존성의 응답을 고정하여 상태를 검증합니다. (assert)

핵심 차이: Mock은 "어떻게 호출되었는가", Stub은 "결과가 올바른가"에 초점을 맞춥니다.

Q4. 코드 커버리지 100%가 좋지 않은 이유는?
  1. 의미 없는 테스트 양산 (getter/setter)
  2. 유지보수 비용 증가
  3. 코드 실행과 올바름 검증은 다름
  4. 중요한 비즈니스 로직에 집중하지 못하게 됨

추천: 핵심 비즈니스 로직 90%+, 전체 70-85%. Branch coverage를 Line coverage보다 중시해야 합니다.

Q5. Flaky Test란? 어떻게 해결하나요?

같은 코드에서 때로는 성공, 때로는 실패하는 테스트입니다.

원인: 타이밍 의존성, 외부 서비스 의존, 테스트 간 상태 공유, 비결정적 데이터.

해결 방법:

  • 하드코딩된 sleep/timeout 대신 조건 기반 대기 사용
  • 외부 의존성 모킹
  • 테스트 간 독립적인 데이터 사용
  • CI에서 재시도(retry) 설정
  • Quarantine(격리) 후 수정
Q6. E2E 테스트에서 Page Object Model의 장점은?
  1. 코드 재사용: 페이지 요소와 동작을 한 곳에서 관리
  2. 유지보수 용이: UI 변경 시 POM만 수정하면 됨
  3. 가독성 향상: 테스트 코드가 비즈니스 로직에 집중
  4. 중복 제거: 동일한 페이지 조작 코드가 여러 테스트에 분산되지 않음
Q7. 통합 테스트와 단위 테스트의 경계는 어디인가요?

단위 테스트: 외부 의존성 없이 메모리 내에서 실행. 모든 I/O를 모킹. 하나의 "유닛"(함수, 클래스)만 검증.

통합 테스트: 실제 외부 시스템(DB, API, 파일 시스템)과 상호작용. 여러 컴포넌트 간 연동 검증. Testcontainers, 테스트 DB 등 사용.

경계: "외부 프로세스/서비스와 통신하는가?" 통신하면 통합, 아니면 단위입니다.

Q8. Mutation Testing이란?

소스 코드에 의도적으로 작은 변경(mutant)을 가하고, 기존 테스트가 그 변경을 감지하는지 확인하는 기법입니다. 테스트가 변경을 감지하면 mutant가 "killed", 감지하지 못하면 "survived"입니다. 생존한 mutant가 많으면 테스트 품질이 낮다는 의미입니다.

도구: Stryker(JS/TS), PIT(Java), mutmut(Python)

Q9. Contract Testing은 언제 필요한가요?

마이크로서비스 아키텍처에서 서비스 간 API 계약을 검증할 때 필요합니다. Provider가 API를 변경해도 Consumer가 기대하는 응답 형식이 유지되는지 확인합니다.

장점: E2E보다 빠르고 안정적, 서비스 독립 배포 가능, API 변경의 영향도를 사전에 파악.

도구: Pact, Spring Cloud Contract

Q10. 테스트 격리(Test Isolation)란?

각 테스트가 다른 테스트의 영향을 받지 않고 독립적으로 실행될 수 있는 것입니다. 순서에 상관없이 같은 결과를 보장해야 합니다.

방법: beforeEach/afterEach로 상태 초기화, 각 테스트에서 고유한 데이터 사용, 전역 상태 변경 금지, DB 트랜잭션 롤백.

Q11. BDD와 TDD의 차이점은?

TDD: 개발자 관점. 코드 단위의 동작을 검증. Red-Green-Refactor 사이클.

BDD (Behavior-Driven Development): 비즈니스 관점. Given-When-Then 형식으로 사용자 시나리오를 기술. 비개발자도 이해할 수 있는 테스트 작성.

BDD는 TDD의 확장으로, 비즈니스 요구사항에서 테스트를 도출합니다.

Q12. 테스트 더블(Test Double)의 종류는?
  1. Dummy: 사용되지 않지만 파라미터를 채우기 위한 객체
  2. Stub: 미리 정해진 값을 반환하는 객체
  3. Spy: 호출 정보를 기록하면서 실제 구현도 실행하는 객체
  4. Mock: 호출 기대값을 설정하고 검증하는 객체
  5. Fake: 실제 구현의 단순화 버전 (예: 인메모리 DB)
Q13. 성능 테스트의 종류를 설명하세요.
  1. Load Test: 예상 부하에서의 성능 확인
  2. Stress Test: 한계치를 넘는 부하에서의 동작 확인
  3. Spike Test: 급격한 부하 증가에 대한 반응 확인
  4. Soak Test: 장시간 일정 부하에서의 안정성 확인 (메모리 누수 등)
  5. Benchmark: 기준선 성능 측정
Q14. 테스트 가능한 코드의 특징은?
  1. 의존성 주입(DI): 외부 의존성을 생성자나 메서드로 주입
  2. 단일 책임 원칙: 하나의 클래스/함수가 하나의 역할만 수행
  3. 순수 함수: 같은 입력에 같은 출력, 부작용 없음
  4. 인터페이스 사용: 구현이 아닌 추상화에 의존
  5. 전역 상태 회피: 싱글톤, 전역 변수 최소화

테스트하기 어렵다면 설계를 재고하라는 신호입니다.

Q15. 회귀 테스트(Regression Test)란?

새로운 코드 변경이 기존 기능을 깨뜨리지 않았는지 확인하는 테스트입니다. 모든 기존 테스트를 재실행하여 변경의 부작용을 감지합니다.

CI/CD에서 자동으로 실행하는 것이 핵심입니다. 버그가 발견될 때마다 해당 버그를 재현하는 테스트를 추가하면 회귀 테스트 스위트가 강화됩니다.


16. 퀴즈

Q1. 다음 Jest 테스트에서 잘못된 부분을 찾으세요.
test('should fetch user data', () => {
  const result = fetchUser('1');
  expect(result.name).toBe('Alice');
});

: fetchUser가 비동기 함수(Promise 반환)라면 await가 필요합니다.

test('should fetch user data', async () => {
  const result = await fetchUser('1');
  expect(result.name).toBe('Alice');
});

async/await 없이 Promise를 반환하면 테스트가 assertion 실행 전에 통과됩니다.

Q2. 테스트 피라미드의 어느 레벨에 해당하나요?

"Testcontainers로 PostgreSQL을 띄우고 UserRepository의 CRUD를 검증하는 테스트"

답: Integration Test

실제 데이터베이스(Docker 컨테이너)를 사용하고, Repository와 DB 간의 상호작용을 검증하므로 통합 테스트입니다. 메모리 내 Mock DB를 사용했다면 단위 테스트에 가깝습니다.

Q3. AAA 패턴이란 무엇인가요?

: Arrange-Act-Assert의 약자로, 테스트 코드의 구조를 나타냅니다.

  1. Arrange: 테스트에 필요한 데이터와 객체를 준비
  2. Act: 테스트 대상 메서드를 실행
  3. Assert: 결과를 검증

이 패턴을 따르면 테스트의 의도가 명확해집니다. BDD에서는 Given-When-Then이 같은 개념입니다.

Q4. 다음 중 Flaky Test의 원인이 아닌 것은?

A) 하드코딩된 sleep 대기 시간 B) 테스트 간 공유 데이터베이스 상태 C) 의존성 주입을 사용한 모킹 D) 외부 API에 직접 의존하는 테스트

답: C

의존성 주입을 통한 모킹은 오히려 테스트를 안정적으로 만드는 기법입니다. A, B, D는 모두 Flaky Test의 대표적인 원인입니다.

Q5. Branch Coverage 100%이지만 버그가 있을 수 있는 이유는?

: Branch Coverage는 코드의 모든 분기(if/else)가 실행되었는지만 확인하며, 다음을 검증하지 않습니다:

  1. 경계값 (off-by-one 에러)
  2. 복합 조건 내 개별 조건의 조합
  3. 예외적 입력 (null, 빈 문자열, 음수)
  4. 동시성 문제 (race condition)
  5. 비즈니스 로직의 정확성

커버리지는 필요 조건이지 충분 조건이 아닙니다.


17. 참고 자료

공식 문서

  1. Jest 공식 문서 - JavaScript 테스팅 프레임워크
  2. pytest 공식 문서 - Python 테스팅 프레임워크
  3. JUnit 5 User Guide - Java 테스팅
  4. Playwright 공식 문서 - E2E 테스팅
  5. Cypress 공식 문서 - E2E 테스팅

도구 및 라이브러리

  1. Testcontainers - Docker 기반 통합 테스트
  2. k6 - 성능/로드 테스트
  3. Stryker Mutator - 뮤테이션 테스트
  4. Pact - 계약 테스트
  5. Istanbul/nyc - JavaScript 코드 커버리지

도서 및 학습

  1. Test Driven Development: By Example (Kent Beck) - TDD의 바이블
  2. Growing Object-Oriented Software, Guided by Tests (Freeman, Pryce) - 객체지향 TDD
  3. The Art of Unit Testing (Roy Osherove) - 단위 테스트 실전
  4. xUnit Test Patterns (Gerard Meszaros) - 테스트 패턴 레퍼런스
  5. Testing Trophy by Kent C. Dodds - 프론트엔드 테스트 전략

CI/CD 및 품질

  1. GitHub Actions Testing Guide - CI 테스트 자동화
  2. Codecov - 커버리지 리포팅
  3. SonarQube - 코드 품질 분석

Complete Software Testing Guide 2025: TDD, Unit/Integration/E2E Testing, and CI/CD Automation

1. Why Testing Matters

The cost of fixing bugs found in production is over 100x higher than when found during development (IBM Systems Sciences Institute study). Testing is not just about finding bugs -- it provides software quality assurance, refactoring safety nets, and serves as living documentation.

Core Values of Testing

  • Confident deployments: When tests pass, you can deploy with confidence
  • Refactoring freedom: Improve code while guaranteeing existing behavior
  • Living documentation: Test code demonstrates how features are used
  • Design improvement: Code that is hard to test signals poor design
  • Regression prevention: Ensures existing features remain intact when adding new ones

2. The Test Pyramid

The test pyramid, proposed by Martin Fowler, is the core framework for testing strategy.

        /\
       /  \        E2E Tests (few)
      /    \       - Slow and expensive
     /------\      - Validates entire system
    /        \
   / Integration\  Integration Tests (moderate)
  /    Tests     \ - Component interactions
 /--------------\ - DB, API integration tests
/                \
/   Unit Tests    \ Unit Tests (many)
/------------------\ - Fast and stable
                     - Individual function/class verification
Test TypeRatioExecution SpeedMaintenance CostReliability
Unit70%Very fastLowMedium
Integration20%ModerateMediumHigh
E2E10%SlowHighVery high

3. TDD (Test-Driven Development)

3.1 Red-Green-Refactor Cycle

TDD consists of three repeating steps:

  1. Red: Write a failing test first
  2. Green: Write minimal code to make the test pass
  3. Refactor: Clean up the code while keeping tests passing

3.2 TDD Practical Example (TypeScript + Jest)

Developing an email validation function with TDD.

Step 1: Red - Write a failing test

// email-validator.test.ts
import { validateEmail } from './email-validator';

describe('validateEmail', () => {
  it('should return true for valid email', () => {
    expect(validateEmail('user@example.com')).toBe(true);
  });

  it('should return false for email without @', () => {
    expect(validateEmail('userexample.com')).toBe(false);
  });

  it('should return false for email without domain', () => {
    expect(validateEmail('user@')).toBe(false);
  });

  it('should return false for empty string', () => {
    expect(validateEmail('')).toBe(false);
  });

  it('should return false for email with spaces', () => {
    expect(validateEmail('user @example.com')).toBe(false);
  });
});

Step 2: Green - Minimal implementation

// email-validator.ts
export function validateEmail(email: string): boolean {
  if (!email || email.includes(' ')) return false;
  const parts = email.split('@');
  if (parts.length !== 2) return false;
  const [local, domain] = parts;
  return local.length > 0 && domain.includes('.');
}

Step 3: Refactor - Improve with regex

// email-validator.ts (refactored)
export function validateEmail(email: string): boolean {
  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  return emailRegex.test(email);
}

3.3 TDD Practical Example (Python + pytest)

# test_calculator.py
import pytest
from calculator import Calculator

class TestCalculator:
    def setup_method(self):
        self.calc = Calculator()

    def test_add(self):
        assert self.calc.add(2, 3) == 5

    def test_add_negative(self):
        assert self.calc.add(-1, -2) == -3

    def test_divide(self):
        assert self.calc.divide(10, 2) == 5.0

    def test_divide_by_zero(self):
        with pytest.raises(ValueError, match="Cannot divide by zero"):
            self.calc.divide(10, 0)

    def test_divide_returns_float(self):
        assert isinstance(self.calc.divide(7, 2), float)
# calculator.py
class Calculator:
    def add(self, a: int, b: int) -> int:
        return a + b

    def divide(self, a: int, b: int) -> float:
        if b == 0:
            raise ValueError("Cannot divide by zero")
        return float(a) / b

4. Unit Testing

4.1 Jest (TypeScript/JavaScript)

// user-service.test.ts
import { UserService } from './user-service';
import { UserRepository } from './user-repository';

jest.mock('./user-repository');
const MockedUserRepo = UserRepository as jest.MockedClass<typeof UserRepository>;

describe('UserService', () => {
  let service: UserService;
  let mockRepo: jest.Mocked<UserRepository>;

  beforeEach(() => {
    mockRepo = new MockedUserRepo() as jest.Mocked<UserRepository>;
    service = new UserService(mockRepo);
    jest.clearAllMocks();
  });

  describe('getUser', () => {
    it('should return user when found', async () => {
      const mockUser = { id: '1', name: 'Alice', email: 'alice@test.com' };
      mockRepo.findById.mockResolvedValue(mockUser);

      const result = await service.getUser('1');

      expect(result).toEqual(mockUser);
      expect(mockRepo.findById).toHaveBeenCalledWith('1');
      expect(mockRepo.findById).toHaveBeenCalledTimes(1);
    });

    it('should throw when user not found', async () => {
      mockRepo.findById.mockResolvedValue(null);

      await expect(service.getUser('999')).rejects.toThrow('User not found');
    });
  });

  describe('createUser', () => {
    it('should create user with hashed password', async () => {
      const input = { name: 'Bob', email: 'bob@test.com', password: 'secret' };
      mockRepo.create.mockResolvedValue({ id: '2', ...input, password: 'hashed' });

      const result = await service.createUser(input);

      expect(result.id).toBe('2');
      expect(mockRepo.create).toHaveBeenCalledTimes(1);
    });

    it('should throw for duplicate email', async () => {
      mockRepo.findByEmail.mockResolvedValue({ id: '1', email: 'bob@test.com' } as any);

      await expect(
        service.createUser({ name: 'Bob', email: 'bob@test.com', password: 'x' })
      ).rejects.toThrow('Email already exists');
    });
  });
});

4.2 pytest (Python)

# test_order_service.py
import pytest
from unittest.mock import MagicMock, patch
from order_service import OrderService

class TestOrderService:
    def setup_method(self):
        self.mock_repo = MagicMock()
        self.mock_payment = MagicMock()
        self.service = OrderService(
            repository=self.mock_repo,
            payment_gateway=self.mock_payment
        )

    def test_create_order_success(self):
        self.mock_payment.charge.return_value = {"status": "success", "tx_id": "tx123"}
        self.mock_repo.save.return_value = {"id": "order1", "total": 100}

        result = self.service.create_order(
            user_id="user1",
            items=[{"product_id": "p1", "quantity": 2, "price": 50}]
        )

        assert result["id"] == "order1"
        self.mock_payment.charge.assert_called_once_with(amount=100, user_id="user1")
        self.mock_repo.save.assert_called_once()

    def test_create_order_payment_failure(self):
        self.mock_payment.charge.side_effect = Exception("Payment declined")

        with pytest.raises(Exception, match="Payment declined"):
            self.service.create_order(
                user_id="user1",
                items=[{"product_id": "p1", "quantity": 1, "price": 50}]
            )

        self.mock_repo.save.assert_not_called()

    @pytest.mark.parametrize("items,expected_total", [
        ([{"price": 10, "quantity": 1}], 10),
        ([{"price": 10, "quantity": 3}], 30),
        ([{"price": 10, "quantity": 2}, {"price": 20, "quantity": 1}], 40),
    ])
    def test_calculate_total(self, items, expected_total):
        total = self.service.calculate_total(items)
        assert total == expected_total

4.3 JUnit 5 (Java)

// UserServiceTest.java
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
class UserServiceTest {

    @Mock
    private UserRepository userRepository;

    @Mock
    private PasswordEncoder passwordEncoder;

    @InjectMocks
    private UserService userService;

    @Test
    @DisplayName("Should return user when valid ID is provided")
    void getUser_ValidId_ReturnsUser() {
        User expected = new User("1", "Alice", "alice@test.com");
        when(userRepository.findById("1")).thenReturn(Optional.of(expected));

        User result = userService.getUser("1");

        assertThat(result.getName()).isEqualTo("Alice");
        verify(userRepository).findById("1");
    }

    @Test
    @DisplayName("Should throw exception when user not found")
    void getUser_InvalidId_ThrowsException() {
        when(userRepository.findById("999")).thenReturn(Optional.empty());

        assertThatThrownBy(() -> userService.getUser("999"))
            .isInstanceOf(UserNotFoundException.class)
            .hasMessage("User not found: 999");
    }

    @Test
    @DisplayName("Should create user with encoded password")
    void createUser_ValidInput_CreatesUserWithEncodedPassword() {
        when(passwordEncoder.encode("rawPassword")).thenReturn("encodedPassword");
        when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));

        User result = userService.createUser("Bob", "bob@test.com", "rawPassword");

        assertThat(result.getPassword()).isEqualTo("encodedPassword");
        verify(passwordEncoder).encode("rawPassword");
    }
}

5. Mocking Strategies

5.1 Mock vs Stub vs Spy

TypePurposeVerification
MockVerify calls and argumentsBehavior verification (verify)
StubReturn predetermined valuesState verification (assert)
SpyCall real implementation + recordBehavior + state verification

5.2 Jest Mocking Patterns

// Module mocking
jest.mock('./api-client', () => ({
  fetchData: jest.fn().mockResolvedValue({ data: 'mocked' }),
}));

// Timer mocking
jest.useFakeTimers();
setTimeout(() => callback(), 1000);
jest.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalled();

// fetch mocking
global.fetch = jest.fn().mockResolvedValue({
  ok: true,
  json: () => Promise.resolve({ users: [] }),
});

5.3 Python unittest.mock Patterns

from unittest.mock import patch, MagicMock
from datetime import datetime

# Decorator pattern
@patch('module.datetime')
def test_greeting_morning(mock_datetime):
    mock_datetime.now.return_value = datetime(2025, 1, 1, 9, 0, 0)
    assert get_greeting() == "Good morning"

# Context manager pattern
def test_api_call():
    with patch('requests.get') as mock_get:
        mock_get.return_value.status_code = 200
        mock_get.return_value.json.return_value = {"status": "ok"}

        result = fetch_status()
        assert result == "ok"
        mock_get.assert_called_once()

5.4 When to Use Mocking

Appropriate for mocking:

  • External API calls (HTTP, gRPC)
  • Database access
  • File system I/O
  • Time-dependent logic (current time, timers)
  • Random value generation

Avoid mocking:

  • Pure functions (results determined solely by inputs)
  • Value objects (DTOs, Entities)
  • Internal methods of the test subject

6. Integration Testing

6.1 Using Testcontainers

Testcontainers spins up real databases in Docker containers for integration testing.

// user-repository.integration.test.ts
import { PostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';
import { UserRepository } from './user-repository';

describe('UserRepository Integration', () => {
  let container: any;
  let pool: Pool;
  let repo: UserRepository;

  beforeAll(async () => {
    container = await new PostgreSqlContainer()
      .withDatabase('testdb')
      .start();

    pool = new Pool({ connectionString: container.getConnectionUri() });
    await pool.query(`
      CREATE TABLE users (
        id SERIAL PRIMARY KEY,
        name VARCHAR(100),
        email VARCHAR(100) UNIQUE
      )
    `);
    repo = new UserRepository(pool);
  }, 60000);

  afterAll(async () => {
    await pool.end();
    await container.stop();
  });

  afterEach(async () => {
    await pool.query('DELETE FROM users');
  });

  it('should save and retrieve user', async () => {
    await repo.create({ name: 'Alice', email: 'alice@test.com' });

    const user = await repo.findByEmail('alice@test.com');

    expect(user).toBeDefined();
    expect(user!.name).toBe('Alice');
  });

  it('should throw on duplicate email', async () => {
    await repo.create({ name: 'Alice', email: 'alice@test.com' });

    await expect(
      repo.create({ name: 'Bob', email: 'alice@test.com' })
    ).rejects.toThrow();
  });
});

6.2 API Integration Testing (supertest)

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

describe('POST /api/users', () => {
  it('should create user and return 201', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@test.com' })
      .expect(201);

    expect(response.body).toMatchObject({
      name: 'Alice',
      email: 'alice@test.com',
    });
    expect(response.body.id).toBeDefined();
  });

  it('should return 400 for invalid email', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'invalid' })
      .expect(400);

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

  it('should return 409 for duplicate email', async () => {
    await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@test.com' });

    await request(app)
      .post('/api/users')
      .send({ name: 'Bob', email: 'alice@test.com' })
      .expect(409);
  });
});

7. E2E Testing

7.1 Playwright vs Cypress Comparison

FeaturePlaywrightCypress
Browser supportChromium, Firefox, WebKitChrome, Firefox, Edge
LanguagesTS/JS, Python, Java, C#JS/TS
Parallel executionNative supportPaid (Cypress Cloud)
Network interceptionPowerful interceptionInterception supported
MobileDevice emulationViewport only
SpeedVery fastFast
DebuggingTrace ViewerTime Travel
CommunityRapidly growingLarge

7.2 Playwright Example

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

test.describe('Login Flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/login');
  });

  test('should login with valid credentials', async ({ page }) => {
    await page.fill('[data-testid="email-input"]', 'user@test.com');
    await page.fill('[data-testid="password-input"]', 'password123');
    await page.click('[data-testid="login-button"]');

    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('[data-testid="welcome-message"]')).toContainText('Welcome');
  });

  test('should show error for invalid credentials', async ({ page }) => {
    await page.fill('[data-testid="email-input"]', 'wrong@test.com');
    await page.fill('[data-testid="password-input"]', 'wrong');
    await page.click('[data-testid="login-button"]');

    await expect(page.locator('[data-testid="error-message"]')).toBeVisible();
    await expect(page).toHaveURL('/login');
  });

  test('should navigate to registration page', async ({ page }) => {
    await page.click('text=Sign up');
    await expect(page).toHaveURL('/register');
  });
});

7.3 Page Object Model (POM)

// pages/login-page.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-input"]');
    this.passwordInput = page.locator('[data-testid="password-input"]');
    this.loginButton = page.locator('[data-testid="login-button"]');
    this.errorMessage = page.locator('[data-testid="error-message"]');
  }

  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);
  }
}

// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/login-page';

test('should login successfully', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('user@test.com', 'password123');
  await expect(page).toHaveURL('/dashboard');
});

7.4 Cypress Example

// cypress/e2e/checkout.cy.js
describe('Checkout Flow', () => {
  beforeEach(() => {
    cy.login('user@test.com', 'password');
    cy.visit('/products');
  });

  it('should complete checkout process', () => {
    // Add product
    cy.get('[data-testid="product-card"]').first().click();
    cy.get('[data-testid="add-to-cart"]').click();
    cy.get('[data-testid="cart-count"]').should('contain', '1');

    // Navigate to cart
    cy.get('[data-testid="cart-icon"]').click();
    cy.url().should('include', '/cart');

    // Proceed to checkout
    cy.get('[data-testid="checkout-button"]').click();
    cy.get('[data-testid="shipping-form"]').within(() => {
      cy.get('input[name="address"]').type('123 Main St');
      cy.get('input[name="city"]').type('Seoul');
    });

    cy.get('[data-testid="confirm-order"]').click();
    cy.get('[data-testid="order-confirmation"]').should('be.visible');
    cy.get('[data-testid="order-number"]').should('exist');
  });

  it('should handle empty cart', () => {
    cy.visit('/cart');
    cy.get('[data-testid="empty-cart-message"]').should('be.visible');
    cy.get('[data-testid="checkout-button"]').should('be.disabled');
  });
});

8. Performance Testing

8.1 k6 Load Testing

// 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 responseTime = new Trend('response_time');

export const options = {
  stages: [
    { duration: '1m', target: 50 },    // Ramp up to 50 users
    { duration: '3m', target: 50 },    // Stay at 50 users
    { duration: '1m', target: 200 },   // Ramp up to 200 users
    { duration: '3m', target: 200 },   // Stay at 200 users
    { duration: '1m', target: 0 },     // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],   // 95% of requests under 500ms
    errors: ['rate<0.01'],              // Error rate under 1%
  },
};

export default function () {
  const res = http.get('https://api.example.com/products');

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
    'body has products': (r) => JSON.parse(r.body).length > 0,
  });

  errorRate.add(res.status !== 200);
  responseTime.add(res.timings.duration);

  sleep(1);
}

Run commands:

k6 run load-test.js
k6 run --out json=results.json load-test.js    # JSON output
k6 run --vus 100 --duration 30s load-test.js   # Quick smoke test

9. Code Coverage

9.1 Coverage Types

TypeDescriptionImportance
Line CoveragePercentage of executed code linesBasic
Branch CoveragePercentage of executed branches (if/else)High
Function CoveragePercentage of called functionsMedium
Statement CoveragePercentage of executed statementsBasic

9.2 Coverage Tool Configuration

Jest (Istanbul):

{
  "jest": {
    "collectCoverage": true,
    "coverageThreshold": {
      "global": {
        "branches": 80,
        "functions": 80,
        "lines": 80,
        "statements": 80
      }
    },
    "coveragePathIgnorePatterns": [
      "/node_modules/",
      "/test/",
      "/__mocks__/"
    ]
  }
}

pytest (coverage.py):

# pyproject.toml
[tool.pytest.ini_options]
addopts = "--cov=src --cov-report=html --cov-report=term-missing --cov-fail-under=80"

[tool.coverage.run]
omit = ["tests/*", "*/migrations/*"]

JaCoCo (Java/Gradle):

// build.gradle
plugins {
    id 'jacoco'
}

jacocoTestReport {
    reports {
        xml.required = true
        html.required = true
    }
}

jacocoTestCoverageVerification {
    violationRules {
        rule {
            limit {
                minimum = 0.80
            }
        }
    }
}

9.3 The Trap of 100% Coverage

Do not aim for 100% coverage. Here is why:

  • Meaningless tests proliferate: Tests for getters/setters and simple delegation methods add little value
  • Increased maintenance burden: More tests break on implementation changes
  • False sense of security: Executing code is not the same as verifying correctness
  • Recommended targets: Core business logic 90%+, overall project 70-85%

10. CI/CD Test Automation

10.1 GitHub Actions Workflow

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

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

jobs:
  unit-test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - run: npm ci
      - run: npm run lint
      - run: npm run test:unit -- --coverage

      - name: Upload coverage
        uses: codecov/codecov-action@v4
        with:
          token: ${{ secrets.CODECOV_TOKEN }}

  integration-test:
    runs-on: ubuntu-latest
    needs: unit-test
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_PASSWORD: testpass
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    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://postgres:testpass@localhost:5432/testdb

  e2e-test:
    runs-on: ubuntu-latest
    needs: integration-test
    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
      - run: npm run test:e2e
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

11. Mutation Testing

Mutation testing validates the quality of your tests. It makes small changes (mutants) to source code and checks whether your tests detect those changes.

11.1 Stryker (JavaScript/TypeScript)

{
  "stryker": {
    "mutate": ["src/**/*.ts", "!src/**/*.test.ts"],
    "testRunner": "jest",
    "reporters": ["html", "clear-text", "progress"],
    "thresholds": {
      "high": 80,
      "low": 60,
      "break": 50
    }
  }
}
npx stryker run

Mutation examples:

  • Condition inversion: if (a > b) changed to if (a <= b)
  • Operator change: a + b changed to a - b
  • Return value change: return true changed to return false

If a mutant survives (tests still pass), it means that area has insufficient test coverage.


12. Contract Testing

12.1 Pact Basics

Contract testing verifies API contracts between microservices. It confirms that the response a Consumer expects matches what the Provider actually sends.

// consumer.test.ts (Consumer side)
import { PactV3 } from '@pact-foundation/pact';

const provider = new PactV3({
  consumer: 'OrderService',
  provider: 'UserService',
});

describe('User API Contract', () => {
  it('should return user details', async () => {
    await provider
      .given('user with ID 1 exists')
      .uponReceiving('a request for user 1')
      .withRequest({
        method: 'GET',
        path: '/api/users/1',
      })
      .willRespondWith({
        status: 200,
        body: {
          id: '1',
          name: 'Alice',
          email: 'alice@example.com',
        },
      })
      .executeTest(async (mockserver) => {
        const response = await fetch(`${mockserver.url}/api/users/1`);
        const data = await response.json();
        expect(data.name).toBe('Alice');
      });
  });
});

13. AI-Assisted Testing

How AI can help with test writing.

13.1 AI Testing Tool Usage

  • GitHub Copilot: Autocompletes test bodies when you write test function signatures
  • ChatGPT/Claude: Generates test code including edge cases when given a function
  • Codium AI: Analyzes code and auto-generates meaningful tests

13.2 Limitations of AI Testing

  • May not fully understand the intent of business logic
  • Generated tests may couple to implementation details
  • Edge case importance judgment may be inaccurate
  • Conclusion: AI is an assistive tool; test design remains the developer's domain

14. Testing Anti-Patterns

14.1 Ten Patterns to Avoid

  1. Ice Cream Cone: Inverted pyramid with many E2E and few Unit tests. Slow and unstable.

  2. Testing implementation details: Directly testing internal methods. Breaks on refactoring.

  3. Tests that never fail: Tests with no assertions or that always pass.

  4. Interdependent tests: Results change depending on execution order.

  5. Slow tests: Entire test suite taking 10+ minutes. Developers avoid running them.

  6. Flaky Tests: Sometimes pass, sometimes fail with same code. Caused by timing and external dependencies.

  7. Over-mocking: Too many mocks create divergence from actual behavior.

  8. Magic numbers: Hard-coded values of unclear meaning in tests. Reduces readability.

  9. Test duplication: Same logic repeated across multiple tests. Apply DRY principle.

  10. Commented-out tests: Commenting out failing tests. Delete or fix them instead.

14.2 Handling Flaky Tests

// Bad: Test dependent on timing
test('should show toast after action', async () => {
  await page.click('#submit');
  await page.waitForTimeout(2000);  // Bad: hard-coded wait time
  expect(await page.isVisible('.toast')).toBe(true);
});

// Good: Condition-based waiting
test('should show toast after action', async () => {
  await page.click('#submit');
  await expect(page.locator('.toast')).toBeVisible({ timeout: 5000 });
});

15. Interview Questions (15)

Q1. What are the characteristics of each test pyramid level and appropriate ratios?

Unit Test (70%): Tests individual functions/classes in isolation. Very fast and stable. External dependencies are mocked.

Integration Test (20%): Tests interactions between components. Verifies actual DB, API integrations. Slower than unit tests but closer to real environment.

E2E Test (10%): Tests entire user scenarios. Runs in browser. Slowest and most unstable but highest reliability.

Q2. What are the pros and cons of TDD?

Pros: High test coverage, better design (testable code), fast feedback, refactoring confidence, reduced debugging time.

Cons: Slower initial development, learning curve, unsuitable for frequently changing prototypes, difficult to apply to legacy code.

Q3. What is the difference between Mock and Stub?

Mock: Verifies behavior. Checks whether methods were called with specific arguments and how many times. (verify)

Stub: Returns predetermined values. Fixes external dependency responses to verify state. (assert)

Key difference: Mocks focus on "how was it called", Stubs focus on "is the result correct".

Q4. Why is 100% code coverage not ideal?
  1. Proliferates meaningless tests (getters/setters)
  2. Increases maintenance costs
  3. Code execution and correctness verification are different things
  4. Distracts from focusing on critical business logic

Recommended: Core business logic 90%+, overall 70-85%. Prioritize branch coverage over line coverage.

Q5. What is a Flaky Test? How do you fix it?

A test that sometimes passes and sometimes fails with the same code.

Causes: Timing dependencies, external service dependencies, shared state between tests, non-deterministic data.

Solutions:

  • Use condition-based waiting instead of hard-coded sleep/timeout
  • Mock external dependencies
  • Use independent data per test
  • Configure retries in CI
  • Quarantine and fix
Q6. What are the advantages of Page Object Model in E2E testing?
  1. Code reuse: Page elements and actions managed in one place
  2. Easy maintenance: Only POM needs updating when UI changes
  3. Improved readability: Test code focuses on business logic
  4. Eliminates duplication: Same page manipulation code not scattered across tests
Q7. Where is the boundary between integration tests and unit tests?

Unit test: Runs in-memory without external dependencies. All I/O is mocked. Verifies a single "unit" (function, class).

Integration test: Interacts with real external systems (DB, API, file system). Verifies integration between components. Uses Testcontainers, test DBs, etc.

Boundary: "Does it communicate with an external process/service?" If yes, it is integration; if not, it is unit.

Q8. What is Mutation Testing?

A technique that makes intentional small changes (mutants) to source code and verifies whether existing tests detect the changes. If tests detect the change, the mutant is "killed"; if not, it "survived". Many surviving mutants indicate low test quality.

Tools: Stryker (JS/TS), PIT (Java), mutmut (Python)

Q9. When is Contract Testing needed?

When verifying API contracts between services in a microservices architecture. Confirms that the response format expected by Consumers is maintained even when Providers change their APIs.

Benefits: Faster and more stable than E2E, enables independent service deployment, identifies API change impact in advance.

Tools: Pact, Spring Cloud Contract

Q10. What is Test Isolation?

Each test can execute independently without being affected by other tests. Must guarantee the same results regardless of execution order.

Methods: Reset state with beforeEach/afterEach, use unique data per test, avoid global state changes, use DB transaction rollbacks.

Q11. What is the difference between BDD and TDD?

TDD: Developer perspective. Verifies code-level behavior. Red-Green-Refactor cycle.

BDD (Behavior-Driven Development): Business perspective. Describes user scenarios in Given-When-Then format. Tests are readable by non-developers.

BDD is an extension of TDD that derives tests from business requirements.

Q12. What are the types of Test Doubles?
  1. Dummy: Object used just to fill parameters, never actually used
  2. Stub: Object that returns predetermined values
  3. Spy: Object that records call information while executing real implementation
  4. Mock: Object with preset expectations that are verified
  5. Fake: Simplified version of real implementation (e.g., in-memory DB)
Q13. Explain the types of performance testing.
  1. Load Test: Verify performance under expected load
  2. Stress Test: Verify behavior beyond capacity limits
  3. Spike Test: Verify response to sudden load increases
  4. Soak Test: Verify stability under sustained load over time (memory leaks, etc.)
  5. Benchmark: Measure baseline performance
Q14. What are characteristics of testable code?
  1. Dependency Injection (DI): External dependencies injected via constructors or methods
  2. Single Responsibility Principle: Each class/function performs one role
  3. Pure functions: Same input yields same output, no side effects
  4. Interface usage: Depend on abstractions, not implementations
  5. Avoid global state: Minimize singletons and global variables

If code is hard to test, it signals that the design needs reconsideration.

Q15. What is Regression Testing?

Testing that verifies new code changes have not broken existing functionality. All existing tests are re-run to detect side effects of changes.

Running automatically in CI/CD is key. Every time a bug is discovered, adding a test that reproduces it strengthens the regression test suite.


16. Quiz

Q1. Find the error in this Jest test.
test('should fetch user data', () => {
  const result = fetchUser('1');
  expect(result.name).toBe('Alice');
});

Answer: If fetchUser is an async function (returns a Promise), await is needed.

test('should fetch user data', async () => {
  const result = await fetchUser('1');
  expect(result.name).toBe('Alice');
});

Without async/await, the test passes before the assertion executes when a Promise is returned.

Q2. Which test pyramid level does this belong to?

"A test that spins up PostgreSQL with Testcontainers and verifies UserRepository CRUD operations"

Answer: Integration Test

It uses a real database (Docker container) and verifies the interaction between Repository and DB, making it an integration test. If it used an in-memory mock DB, it would be closer to a unit test.

Q3. What is the AAA pattern?

Answer: Stands for Arrange-Act-Assert, representing the structure of test code.

  1. Arrange: Prepare data and objects needed for the test
  2. Act: Execute the method under test
  3. Assert: Verify the results

Following this pattern makes test intent clear. In BDD, Given-When-Then represents the same concept.

Q4. Which of the following is NOT a cause of Flaky Tests?

A) Hard-coded sleep wait times B) Shared database state between tests C) Mocking using dependency injection D) Tests that directly depend on external APIs

Answer: C

Mocking through dependency injection actually makes tests more stable. A, B, and D are all classic causes of Flaky Tests.

Q5. Why can bugs exist even with 100% Branch Coverage?

Answer: Branch Coverage only confirms that all code branches (if/else) were executed. It does not verify:

  1. Boundary values (off-by-one errors)
  2. Individual condition combinations within compound conditions
  3. Exceptional inputs (null, empty strings, negative numbers)
  4. Concurrency issues (race conditions)
  5. Correctness of business logic

Coverage is a necessary condition, not a sufficient one.


17. References

Official Documentation

  1. Jest Documentation - JavaScript testing framework
  2. pytest Documentation - Python testing framework
  3. JUnit 5 User Guide - Java testing
  4. Playwright Documentation - E2E testing
  5. Cypress Documentation - E2E testing

Tools and Libraries

  1. Testcontainers - Docker-based integration testing
  2. k6 - Performance/load testing
  3. Stryker Mutator - Mutation testing
  4. Pact - Contract testing
  5. Istanbul/nyc - JavaScript code coverage

Books and Learning

  1. Test Driven Development: By Example (Kent Beck) - The TDD bible
  2. Growing Object-Oriented Software, Guided by Tests (Freeman, Pryce) - OO TDD
  3. The Art of Unit Testing (Roy Osherove) - Practical unit testing
  4. xUnit Test Patterns (Gerard Meszaros) - Test pattern reference
  5. Testing Trophy by Kent C. Dodds - Frontend testing strategy

CI/CD and Quality

  1. GitHub Actions Testing Guide - CI test automation
  2. Codecov - Coverage reporting
  3. SonarQube - Code quality analysis