Split View: 소프트웨어 테스트 전략 완전 가이드 2025: 단위/통합/E2E, TDD, 테스트 피라미드
소프트웨어 테스트 전략 완전 가이드 2025: 단위/통합/E2E, TDD, 테스트 피라미드
- 도입
- 1. 테스트 피라미드와 그 변형들
- 2. 단위 테스트 (Unit Testing)
- 3. 통합 테스트 (Integration Testing)
- 4. E2E 테스트 (End-to-End Testing)
- 5. TDD (Test-Driven Development)
- 6. BDD (Behavior-Driven Development)
- 7. 모킹 전략 (Mocking Strategies)
- 8. 테스트 커버리지
- 9. 컴포넌트 테스트 (React)
- 10. 성능 테스트
- 11. Contract Testing (계약 테스트)
- 12. CI 통합 전략
- 13. 실전 퀴즈
- 14. 테스트 전략 체크리스트
- 15. 참고 자료
도입
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%)
/ \ - 빠르고 저렴
/________________\ - 비즈니스 로직 검증
| 계층 | 비율 | 실행 속도 | 유지보수 비용 | 신뢰도 |
|---|---|---|---|---|
| E2E | 10% | 느림 (분 단위) | 높음 | 매우 높음 |
| 통합 | 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' },
},
});
| 기능 | Jest | Vitest |
|---|---|---|
| 실행 속도 | 보통 | 매우 빠름 (Vite 기반) |
| 설정 | 별도 설정 필요 | Vite 설정 공유 |
| ESM 지원 | 실험적 | 네이티브 |
| 호환성 | Jest API | Jest 호환 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 비교
| 기능 | Playwright | Cypress |
|---|---|---|
| 브라우저 지원 | Chromium, Firefox, WebKit | Chromium 기반 |
| 언어 | JS/TS, Python, Java, .NET | JS/TS |
| 병렬 실행 | 네이티브 지원 | 유료 (Cypress Cloud) |
| 네트워크 모킹 | 내장 route API | cy.intercept |
| iframe 지원 | 완전 지원 | 제한적 |
| 탭/윈도우 | 다중 지원 | 미지원 |
| 실행 속도 | 빠름 | 보통 |
| 디버깅 | Trace Viewer, Codegen | Time 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 합리적인 커버리지 목표
| 프로젝트 유형 | Line | Branch | 비고 |
|---|---|---|---|
| 핵심 비즈니스 로직 | 90%+ | 85%+ | 금융, 결제 관련 |
| 일반 백엔드 API | 80%+ | 75%+ | CRUD 포함 |
| 프론트엔드 UI | 70%+ | 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. 참고 자료
- Testing JavaScript - Kent C. Dodds: testingjavascript.com
- Playwright Documentation: playwright.dev
- Vitest Documentation: vitest.dev
- Pytest Documentation: docs.pytest.org
- JUnit 5 User Guide: junit.org/junit5
- Martin Fowler - Test Pyramid: martinfowler.com/bliki/TestPyramid.html
- Testcontainers: testcontainers.com
- MSW (Mock Service Worker): mswjs.io
- k6 Load Testing: k6.io
- Pact Contract Testing: pact.io
- Stryker Mutator: stryker-mutator.io
- Google Testing Blog: testing.googleblog.com
- Cypress Documentation: docs.cypress.io
- React Testing Library: testing-library.com/react
Software Testing Strategies Complete Guide 2025: Unit/Integration/E2E, TDD, Test Pyramid
- Introduction
- 1. The Test Pyramid and Its Variants
- 2. Unit Testing
- 3. Integration Testing
- 4. E2E Testing (End-to-End Testing)
- 5. TDD (Test-Driven Development)
- 6. BDD (Behavior-Driven Development)
- 7. Mocking Strategies
- 8. Test Coverage
- 9. Component Testing (React)
- 10. Performance Testing
- 11. Contract Testing
- 12. CI Integration
- 13. Quiz
- 14. Testing Strategy Checklist
- 15. References
Introduction
In 2024, Kent Beck emphasized that "not writing tests is like driving without a seatbelt." As software grows increasingly complex, testing strategy is not optional -- it is essential. Netflix runs millions of tests daily across 10,000+ microservices, and Google continuously tests its entire codebase.
Yet many teams struggle to answer fundamental questions: "What tests should we write, how many, and how?" From the test pyramid to TDD/BDD, mocking strategies, and CI integration -- this guide systematically covers the testing strategies you need in practice in 2025.
1. The Test Pyramid and Its Variants
1.1 The Traditional Test Pyramid
The test pyramid, proposed by Mike Cohn in 2009, is the foundational framework for test strategy.
/\
/ \ E2E Tests (10%)
/ \ - Slow and expensive
/------\ - Full system verification
/ \ Integration Tests (20%)
/ \ - Service interactions
/------------\ Unit Tests (70%)
/ \ - Fast and cheap
/________________\ - Business logic verification
| Layer | Ratio | Speed | Maintenance Cost | Confidence |
|---|---|---|---|---|
| E2E | 10% | Slow (minutes) | High | Very High |
| Integration | 20% | Medium (seconds) | Medium | High |
| Unit | 70% | Fast (ms) | Low | Medium |
1.2 The Testing Trophy
Kent C. Dodds proposed the Testing Trophy, emphasizing integration tests from a frontend perspective.
___
| E | E2E (few)
|___|
/ \
/ Integ \ Integration (most)
/_________\
| Unit | Unit (moderate)
|_________|
| Static | Static Analysis (baseline)
|_________|
Core Philosophy: "The more your tests resemble the way your software is used, the more confidence they can give you."
1.3 The Testing Diamond
An approach adopted by Spotify for large-scale backend systems.
/\ E2E (few)
/ \
/ \
/------\ Integration (many)
\ / - DB, API, message queues
\ /
\ / Unit (moderate)
\/ - Pure business logic
1.4 Which Model Should You Choose?
| Project Type | Recommended Model | Reason |
|---|---|---|
| Frontend SPA | Testing Trophy | Component integration is key |
| Backend API | Testing Diamond | DB/external service integration matters |
| Microservices | Test Pyramid | Service isolation is key |
| Fullstack Monolith | Hybrid | Mix optimal strategies per layer |
2. Unit Testing
2.1 The AAA Pattern
The fundamental structure for all unit tests.
// Arrange-Act-Assert pattern
describe('calculateDiscount', () => {
it('applies 20% discount for VIP customers', () => {
// 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 Comparison
// ========== Jest Configuration ==========
// 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 Configuration ==========
// 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' },
},
});
| Feature | Jest | Vitest |
|---|---|---|
| Speed | Moderate | Very fast (Vite-based) |
| Configuration | Separate config | Shares Vite config |
| ESM Support | Experimental | Native |
| Compatibility | Jest API | Jest-compatible API |
| HMR | None | Supported (watch mode) |
| Ecosystem | Very large | Growing rapidly |
2.3 Pytest (Python Backend)
# test_order_service.py
import pytest
from decimal import Decimal
from order_service import OrderService, Order, OrderItem
class TestOrderService:
"""Order service unit tests"""
@pytest.fixture
def order_service(self):
return OrderService()
@pytest.fixture
def sample_items(self):
return [
OrderItem(name="Laptop", price=Decimal("1200.00"), quantity=1),
OrderItem(name="Mouse", price=Decimal("50.00"), quantity=2),
]
def test_calculate_total(self, order_service, sample_items):
"""Correctly calculates the total amount"""
total = order_service.calculate_total(sample_items)
assert total == Decimal("1300.00")
def test_apply_bulk_discount(self, order_service, sample_items):
"""Applies 10% discount for orders over 1000"""
total = order_service.calculate_total(sample_items)
discounted = order_service.apply_discount(total, "BULK")
assert discounted == Decimal("1170.00")
@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):
"""Verifies discount rates by coupon code"""
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("Create Order Tests")
class CreateOrderTest {
@Test
@DisplayName("Successfully creates a valid order")
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("Throws exception for empty item list")
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("Calculates final price based on discount rate")
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 Database Integration Tests 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 () => {
// Start a real PostgreSQL container
container = await new PostgreSqlContainer('postgres:16')
.withDatabase('testdb')
.withUsername('test')
.withPassword('test')
.start();
// Connect Prisma
prisma = new PrismaClient({
datasources: {
db: { url: container.getConnectionUri() },
},
});
// Run migrations
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('can create and retrieve an order', async () => {
const order = await prisma.order.create({
data: {
userId: 'user-123',
total: 500.00,
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(500.00);
});
it('can list orders by user', async () => {
await prisma.order.createMany({
data: [
{ userId: 'user-A', total: 100.00, status: 'completed' },
{ userId: 'user-A', total: 200.00, status: 'pending' },
{ userId: 'user-B', total: 300.00, 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="John Doe", email="john@example.com")
assert user.id is not None
found = repo.find_by_id(user.id)
assert found.name == "John Doe"
assert found.email == "john@example.com"
def test_find_by_email(self, db_session):
repo = UserRepository(db_session)
repo.create(name="Jane Doe", email="jane@example.com")
found = repo.find_by_email("jane@example.com")
assert found is not None
assert found.name == "Jane Doe"
3.3 API Integration Testing
// 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('creates an order with valid request (201)', async () => {
const response = await request(app)
.post('/api/orders')
.send({
userId: 'user-123',
items: [
{ productId: 'prod-1', quantity: 2, price: 15.00 },
],
})
.expect(201);
expect(response.body).toMatchObject({
id: expect.any(String),
status: 'pending',
total: 30.00,
});
});
it('returns 400 when required fields are missing', async () => {
const response = await request(app)
.post('/api/orders')
.send({ userId: 'user-123' })
.expect(400);
expect(response.body.error).toContain('items');
});
});
4. E2E Testing (End-to-End Testing)
4.1 Playwright vs Cypress Comparison
| Feature | Playwright | Cypress |
|---|---|---|
| Browser Support | Chromium, Firefox, WebKit | Chromium-based |
| Languages | JS/TS, Python, Java, .NET | JS/TS |
| Parallel Execution | Native support | Paid (Cypress Cloud) |
| Network Mocking | Built-in route API | cy.intercept |
| iframe Support | Full support | Limited |
| Tabs/Windows | Multi-tab support | Not supported |
| Speed | Fast | Moderate |
| Debugging | Trace Viewer, Codegen | Time Travel |
| Community | Growing rapidly | Mature ecosystem |
4.2 Playwright Practical Example
// tests/checkout.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Checkout Flow', () => {
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('complete flow from adding product to payment', async ({ page }) => {
// 1. Navigate to products
await page.goto('/products');
// 2. Add product to cart
await page.click('[data-testid="product-card-1"] button');
await expect(page.locator('[data-testid="cart-count"]'))
.toHaveText('1');
// 3. Go to cart
await page.click('[data-testid="cart-icon"]');
await expect(page).toHaveURL('/cart');
// 4. Change quantity
await page.fill('[data-testid="quantity-input"]', '3');
await expect(page.locator('[data-testid="subtotal"]'))
.toContainText('$45.00');
// 5. Proceed to checkout
await page.click('[data-testid="checkout-button"]');
// 6. Enter shipping info
await page.fill('#address', '123 Test Street, Suite 456');
await page.fill('#phone', '555-0123');
// 7. Complete payment
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('shows error message when out of stock', async ({ page }) => {
// Mock API: out of stock response
await page.route('**/api/checkout', (route) => {
route.fulfill({
status: 409,
body: JSON.stringify({
error: 'OUT_OF_STOCK',
message: 'Item is out of stock',
}),
});
});
await page.goto('/cart');
await page.click('[data-testid="checkout-button"]');
await expect(page.locator('[data-testid="error-message"]'))
.toContainText('out of stock');
});
});
4.3 Page Object Model (POM) Pattern
// 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('navigates to dashboard after successful login', 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('John Doe');
});
4.4 Visual Regression Testing
// visual.spec.ts
import { test, expect } from '@playwright/test';
test('main page visual regression', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveScreenshot('main-page.png', {
maxDiffPixelRatio: 0.01,
threshold: 0.2,
});
});
test('dark mode visual test', async ({ page }) => {
await page.goto('/');
await page.click('[data-testid="theme-toggle"]');
await expect(page).toHaveScreenshot('main-page-dark.png');
});
test('responsive design 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 The Red-Green-Refactor Cycle
┌──────────┐ ┌──────────┐ ┌──────────┐
│ RED │───▶│ GREEN │───▶│ REFACTOR │
│ Write │ │ Minimal │ │ Improve │
│ failing │ │ passing │ │ code, │
│ test │ │ code │ │ keep │
└──────────┘ └──────────┘ │ tests │
▲ └─────┬────┘
└────────────────────────────────┘
5.2 TDD Practical Example: Password Validator
// ========== Step 1: RED - Write a failing test ==========
describe('PasswordValidator', () => {
it('fails for passwords shorter than 8 characters', () => {
expect(validatePassword('Ab1!xyz')).toEqual({
valid: false,
errors: ['Password must be at least 8 characters'],
});
});
});
// ========== Step 2: GREEN - Minimal implementation ==========
export function validatePassword(password: string) {
const errors: string[] = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters');
}
return { valid: errors.length === 0, errors };
}
// ========== Step 3: RED - Add next test ==========
it('fails when no uppercase letter is present', () => {
expect(validatePassword('abcd1234!')).toEqual({
valid: false,
errors: ['Must contain at least 1 uppercase letter'],
});
});
// ========== Step 4: GREEN - Extend implementation ==========
export function validatePassword(password: string) {
const errors: string[] = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters');
}
if (!/[A-Z]/.test(password)) {
errors.push('Must contain at least 1 uppercase letter');
}
return { valid: errors.length === 0, errors };
}
// ========== Step 5: Add more rules ==========
it('fails when no lowercase letter is present', () => {
expect(validatePassword('ABCD1234!')).toEqual({
valid: false,
errors: ['Must contain at least 1 lowercase letter'],
});
});
it('fails when no digit is present', () => {
expect(validatePassword('Abcdefgh!')).toEqual({
valid: false,
errors: ['Must contain at least 1 digit'],
});
});
it('fails when no special character is present', () => {
expect(validatePassword('Abcdefg1')).toEqual({
valid: false,
errors: ['Must contain at least 1 special character'],
});
});
it('passes when all conditions are met', () => {
expect(validatePassword('Abcdefg1!')).toEqual({
valid: true,
errors: [],
});
});
// ========== Step 6: REFACTOR - Clean up ==========
interface ValidationRule {
test: (password: string) => boolean;
message: string;
}
const rules: ValidationRule[] = [
{ test: (p) => p.length >= 8, message: 'Password must be at least 8 characters' },
{ test: (p) => /[A-Z]/.test(p), message: 'Must contain at least 1 uppercase letter' },
{ test: (p) => /[a-z]/.test(p), message: 'Must contain at least 1 lowercase letter' },
{ test: (p) => /\d/.test(p), message: 'Must contain at least 1 digit' },
{ test: (p) => /[!@#$%^&*]/.test(p), message: 'Must contain at least 1 special character' },
];
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 Pros and Cons
Pros:
- Improved design (testable code = good design)
- Regression bug prevention
- Tests serve as documentation (tests are specs)
- Confidence when refactoring
Cons:
- Slower initial development speed
- Learning curve exists
- UI-related TDD is challenging
- Risk of over-testing
6. BDD (Behavior-Driven Development)
6.1 Cucumber/Gherkin Syntax
# features/order.feature
Feature: Online Ordering
As a user
I want to be able to place orders
Background:
Given a logged-in user exists
Scenario: Successful order
Given the cart contains 1 "Laptop"
And the cart contains 2 "Mouse"
When the user clicks the order button
Then the order is created successfully
And the order status is "Payment Pending"
And a confirmation email is sent
Scenario: Out of stock
Given the cart contains 1 "Limited Edition Keyboard"
And the "Limited Edition Keyboard" has 0 in stock
When the user clicks the order button
Then "Out of stock" message is displayed
Scenario Outline: Shipping cost calculation
Given the order total is <total>
When shipping cost is calculated
Then the shipping cost is <shipping>
Examples:
| total | shipping |
| 30.00 | 5.00 |
| 50.00 | 0.00 |
| 100.00 | 0.00 |
7. Mocking Strategies
7.1 Mock vs Stub vs Spy vs Fake
┌──────────────────────────────────────────────────────────────┐
│ Test Doubles Classification │
├──────────┬───────────────────────────────────────────────────┤
│ Dummy │ Fills parameters. Never actually used │
│ Stub │ Returns predefined values. Fixed responses │
│ Spy │ Real implementation + call recording │
│ Mock │ Pre-set expected calls. Verifies behavior │
│ Fake │ Simple working implementation (in-memory DB, etc.) │
└──────────┴───────────────────────────────────────────────────┘
7.2 Jest/Vitest Mocking Patterns
// ========== Function Mocking ==========
import { vi, describe, it, expect } from 'vitest';
import { sendEmail } from './emailService';
import { createOrder } from './orderService';
// Mock entire module
vi.mock('./emailService', () => ({
sendEmail: vi.fn().mockResolvedValue({ sent: true }),
}));
describe('createOrder', () => {
it('sends email after creating order', async () => {
const order = await createOrder({ userId: 'user-1', items: [] });
// Verify call (Spy pattern)
expect(sendEmail).toHaveBeenCalledWith(
expect.objectContaining({
to: expect.any(String),
subject: expect.stringContaining('order'),
})
);
});
});
// ========== API Mocking (MSW) ==========
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
const server = setupServer(
http.get('/api/products', () => {
return HttpResponse.json([
{ id: '1', name: 'Laptop', price: 1200.00 },
{ id: '2', name: 'Mouse', price: 50.00 },
]);
}),
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 When Should You Mock?
| Mock This | Do Not Mock This |
|---|---|
| External API calls | Pure functions/business logic |
| Database (unit tests) | Value Objects |
| File system | The test subject itself |
| Time/Date functions | Simple helper functions |
| Email/SMS sending | Nearby dependencies |
| Payment gateways | Data transformation logic |
The Golden Rule of Mocking: "Don't mock what you don't own."
8. Test Coverage
8.1 Coverage Types
┌─────────────────────────────────────────────────────┐
│ Coverage Types │
├──────────────┬──────────────────────────────────────┤
│ Line │ Percentage of executed lines │
│ Branch │ Percentage of branches covered │
│ Function │ Percentage of functions called │
│ Statement │ Percentage of statements executed │
└──────────────┴──────────────────────────────────────┘
8.2 Mutation Testing
Mutation testing compensates for the limitations of traditional coverage.
// Original code
function isAdult(age: number): boolean {
return age >= 18;
}
// Mutation 1: Boundary change
function isAdult(age: number): boolean {
return age > 18; // changed >= to >
}
// Mutation 2: Operator change
function isAdult(age: number): boolean {
return age <= 18; // changed >= to <=
}
// If tests don't catch these mutations = weak tests
# Stryker (JavaScript/TypeScript)
npx stryker run
# Example output
# Mutation score: 85%
# Survived mutants: 12
# Killed mutants: 68
# Timeouts: 3
8.3 Reasonable Coverage Targets
| Project Type | Line | Branch | Notes |
|---|---|---|---|
| Core Business Logic | 90%+ | 85%+ | Financial, payment-related |
| General Backend API | 80%+ | 75%+ | Including CRUD |
| Frontend UI | 70%+ | 65%+ | Excluding styles |
| Utility Libraries | 95%+ | 90%+ | Reusable code |
Avoid the Coverage Trap: 100% coverage does not guarantee zero bugs. Meaningful assertions matter more.
9. Component Testing (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('displays results after entering a search term', async () => {
const user = userEvent.setup();
const onSearch = vi.fn().mockResolvedValue([
{ id: 1, title: 'React Basics' },
{ id: 2, title: 'Advanced React' },
]);
render(<SearchBar onSearch={onSearch} />);
const input = screen.getByPlaceholderText('Enter search term');
await user.type(input, 'React');
await user.click(screen.getByRole('button', { name: 'Search' }));
await waitFor(() => {
expect(screen.getByText('React Basics')).toBeInTheDocument();
expect(screen.getByText('Advanced React')).toBeInTheDocument();
});
expect(onSearch).toHaveBeenCalledWith('React');
});
it('shows error for empty search', async () => {
const user = userEvent.setup();
render(<SearchBar onSearch={vi.fn()} />);
await user.click(screen.getByRole('button', { name: 'Search' }));
expect(screen.getByText('Please enter a search term'))
.toBeInTheDocument();
});
});
10. Performance Testing
10.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 orderDuration = new Trend('order_duration');
export const options = {
stages: [
{ duration: '2m', target: 50 }, // Warm up
{ duration: '5m', target: 200 }, // Ramp up
{ duration: '3m', target: 500 }, // Peak load
{ duration: '2m', target: 0 }, // Cool down
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95th percentile under 500ms
errors: ['rate<0.01'], // Error rate under 1%
order_duration: ['p(99)<1000'], // 99th percentile under 1s
},
};
export default function () {
// List products
const productsRes = http.get('http://localhost:3000/api/products');
check(productsRes, {
'products 200': (r) => r.status === 200,
'response under 200ms': (r) => r.timings.duration < 200,
});
// Create order
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, {
'order created 201': (r) => r.status === 201,
});
sleep(1);
}
11. Contract Testing
11.1 Consumer-Driven Contracts with Pact
// consumer.pact.test.ts (Frontend - Consumer)
import { PactV3 } from '@pact-foundation/pact';
const provider = new PactV3({
consumer: 'frontend-app',
provider: 'order-api',
});
describe('Order API Contract', () => {
it('fetches order list', async () => {
provider
.given('orders exist')
.uponReceiving('a request for order list')
.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: 500.00,
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 Integration
12.1 GitHub Actions Test Pipeline
# .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
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
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
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: playwright-report/
12.2 Parallel Test Execution
# Playwright sharding example
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. Quiz
Q1: What is the correct ratio in the test pyramid?
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%
Answer: B
The test pyramid has the most unit tests and the fewest E2E tests. Unit tests are fast with low maintenance cost, while E2E tests are slow and expensive.
Q2: What is the correct order of TDD's Red-Green-Refactor cycle?
A) Write code - Write test - Refactor B) Write failing test - Minimal implementation - Refactor C) Refactor - Write test - Write code D) Minimal implementation - Write test - Refactor
Answer: B
TDD starts with writing a failing test (Red), then writing minimal code to pass the test (Green), then improving the code while keeping tests passing (Refactor).
Q3: What is the main difference between Mock and Stub?
A) Mocks are faster, Stubs are slower B) Mocks verify behavior (calls), Stubs verify state (return values) C) Mocks are for frontend, Stubs are for backend D) No difference
Answer: B
Stubs focus on returning predefined values (state verification), while Mocks verify whether specific methods were called with specific arguments (behavior verification).
Q4: What is the biggest difference between Playwright and Cypress?
A) Playwright is paid, Cypress is free B) Playwright supports multi-browser/tab, Cypress supports single tab only C) Cypress is faster D) Playwright only supports JavaScript
Answer: B
Playwright supports Chromium, Firefox, and WebKit with multi-tab/window testing. Cypress runs in Chromium-based browsers with a single tab. Playwright supports JS/TS, Python, Java, and .NET.
Q5: What is the purpose of Mutation Testing?
A) Measures code performance B) Evaluates the quality (strength) of test code C) Finds security vulnerabilities D) Checks code style
Answer: B
Mutation testing introduces small changes (operator changes, condition inversions, etc.) to source code and checks whether existing tests detect these mutations. Undetected mutations indicate weak tests.
14. Testing Strategy Checklist
Project Testing Strategy Checklist:
[ ] 1. Choose testing model (Pyramid/Trophy/Diamond)
[ ] 2. Select testing framework (Jest/Vitest/Pytest/JUnit)
[ ] 3. Unit tests - Cover business logic
[ ] 4. Integration tests - Verify DB/API integration
[ ] 5. E2E tests - Critical user flows
[ ] 6. Establish mocking strategy
[ ] 7. CI pipeline integration
[ ] 8. Set coverage targets
[ ] 9. Monitor flaky tests
[ ] 10. Performance testing (k6/Artillery)
15. References
- Testing JavaScript - Kent C. Dodds: testingjavascript.com
- Playwright Documentation: playwright.dev
- Vitest Documentation: vitest.dev
- Pytest Documentation: docs.pytest.org
- JUnit 5 User Guide: junit.org/junit5
- Martin Fowler - Test Pyramid: martinfowler.com/bliki/TestPyramid.html
- Testcontainers: testcontainers.com
- MSW (Mock Service Worker): mswjs.io
- k6 Load Testing: k6.io
- Pact Contract Testing: pact.io
- Stryker Mutator: stryker-mutator.io
- Google Testing Blog: testing.googleblog.com
- Cypress Documentation: docs.cypress.io
- React Testing Library: testing-library.com/react