AgentSkillsCN

testing-strategy

测试策略——比起100%的覆盖率,更重要的是正确的测试方法。

SKILL.md
--- frontmatter
name: testing-strategy
description: 테스트 전략 - 100% 커버리지보다 중요한 것은 올바른 테스트

테스트 전략

핵심 철학

"테스트는 버그를 찾는 것이 아니라, 자신감을 얻는 것이다."

1. 테스트 피라미드

code
         /\
        /E2E\        ← 적게 (느리고 비쌈)
       /------\
      /  통합  \      ← 적당히 (중간)
     /----------\
    /    단위    \    ← 많이 (빠르고 저렴)
   /-------------\

2. 우선순위

  1. 핵심 비즈니스 로직 (결제, 주문, 인증)
  2. 버그가 자주 발생하는 부분
  3. 리팩토링할 부분 (테스트가 안전망)
  4. 공개 API / 라이브러리

3. 테스트하지 않아도 되는 것

  • 외부 라이브러리 (이미 테스트됨)
  • 단순한 getter/setter
  • 프레임워크 코드
  • 정적 타입으로 검증 가능한 것

단위 테스트 (Unit Test)

테스트할 것

typescript
// ✅ 테스트해야 할 것: 비즈니스 로직
export function calculateDiscount(
  price: number,
  discountRate: number
): number {
  if (price < 0 || discountRate < 0 || discountRate > 100) {
    throw new Error('유효하지 않은 입력입니다');
  }

  return price * (1 - discountRate / 100);
}

// 테스트
describe('calculateDiscount', () => {
  it('정상적인 할인 계산', () => {
    expect(calculateDiscount(10000, 10)).toBe(9000);
  });

  it('할인율 0%', () => {
    expect(calculateDiscount(10000, 0)).toBe(10000);
  });

  it('할인율 100%', () => {
    expect(calculateDiscount(10000, 100)).toBe(0);
  });

  it('음수 가격은 에러', () => {
    expect(() => calculateDiscount(-10000, 10)).toThrow();
  });

  it('유효하지 않은 할인율은 에러', () => {
    expect(() => calculateDiscount(10000, 101)).toThrow();
  });
});

❌ 테스트하지 않아도 되는 것

typescript
// ❌ 단순한 타입 변환 (TypeScript가 보장)
export function getName(user: User): string {
  return user.name;
}

// ❌ 외부 라이브러리 (이미 테스트됨)
import dayjs from 'dayjs';
export function formatDate(date: Date): string {
  return dayjs(date).format('YYYY-MM-DD');
}

통합 테스트 (Integration Test)

API 엔드포인트 테스트

typescript
// tests/api/users.test.ts
import request from 'supertest';
import app from '@/app';
import { prisma } from '@/lib/prisma';

describe('POST /api/users', () => {
  // 테스트 전: DB 초기화
  beforeEach(async () => {
    await prisma.user.deleteMany();
  });

  // 테스트 후: 정리
  afterAll(async () => {
    await prisma.$disconnect();
  });

  it('사용자 생성 성공', async () => {
    const res = await request(app)
      .post('/api/users')
      .send({
        email: 'test@example.com',
        name: '홍길동',
        password: 'password123',
      })
      .expect(201);

    expect(res.body.user).toMatchObject({
      email: 'test@example.com',
      name: '홍길동',
    });
    expect(res.body.user.password).toBeUndefined(); // 비밀번호 노출 안 됨
  });

  it('중복 이메일은 409 에러', async () => {
    // 첫 번째 사용자 생성
    await request(app).post('/api/users').send({
      email: 'test@example.com',
      name: '홍길동',
      password: 'password123',
    });

    // 같은 이메일로 다시 시도
    await request(app)
      .post('/api/users')
      .send({
        email: 'test@example.com',
        name: '김철수',
        password: 'password456',
      })
      .expect(409);
  });

  it('유효하지 않은 이메일은 400 에러', async () => {
    await request(app)
      .post('/api/users')
      .send({
        email: 'invalid-email',
        name: '홍길동',
        password: 'password123',
      })
      .expect(400);
  });
});

E2E 테스트

Playwright 예시

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

test.describe('결제 플로우', () => {
  test('상품 구매부터 결제까지', async ({ page }) => {
    // 1. 로그인
    await page.goto('/login');
    await page.fill('input[name="email"]', 'test@example.com');
    await page.fill('input[name="password"]', 'password123');
    await page.click('button[type="submit"]');

    // 2. 상품 페이지
    await page.goto('/products/1');
    await page.click('button:has-text("장바구니 담기")');

    // 3. 장바구니
    await page.click('a:has-text("장바구니")');
    await expect(page.locator('.cart-item')).toHaveCount(1);

    // 4. 주문하기
    await page.click('button:has-text("주문하기")');

    // 5. 배송지 입력
    await page.fill('input[name="recipient"]', '홍길동');
    await page.fill('input[name="phone"]', '010-1234-5678');
    await page.fill('input[name="address"]', '서울시 강남구');

    // 6. 결제
    await page.click('button:has-text("결제하기")');

    // 7. 완료 확인
    await expect(page).toHaveURL(/\/order\/complete/);
    await expect(page.locator('h1')).toContainText('주문 완료');
  });
});

테스트 더블 (Mock, Stub, Spy)

Mock: 외부 의존성 대체

typescript
// src/services/payment.ts
export async function processPayment(orderId: string) {
  const response = await fetch('https://api.payment.com/charge', {
    method: 'POST',
    body: JSON.stringify({ orderId }),
  });

  return response.json();
}

// tests/payment.test.ts
import { processPayment } from '@/services/payment';

// fetch Mock
global.fetch = jest.fn();

describe('processPayment', () => {
  beforeEach(() => {
    (fetch as jest.Mock).mockClear();
  });

  it('결제 성공', async () => {
    (fetch as jest.Mock).mockResolvedValueOnce({
      json: async () => ({ success: true, transactionId: 'tx123' }),
    });

    const result = await processPayment('order123');

    expect(fetch).toHaveBeenCalledWith(
      'https://api.payment.com/charge',
      expect.objectContaining({
        method: 'POST',
      })
    );
    expect(result.success).toBe(true);
  });

  it('결제 실패', async () => {
    (fetch as jest.Mock).mockRejectedValueOnce(
      new Error('Network error')
    );

    await expect(processPayment('order123')).rejects.toThrow();
  });
});

Spy: 함수 호출 감시

typescript
import * as logger from '@/lib/logger';

describe('사용자 생성', () => {
  it('생성 시 로그 기록', async () => {
    const logSpy = jest.spyOn(logger, 'info');

    await createUser({ email: 'test@example.com', name: '홍길동' });

    expect(logSpy).toHaveBeenCalledWith(
      'User created',
      expect.objectContaining({
        email: 'test@example.com',
      })
    );

    logSpy.mockRestore();
  });
});

React 컴포넌트 테스트

React Testing Library

typescript
// components/LoginForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { LoginForm } from './LoginForm';

describe('LoginForm', () => {
  it('로그인 폼 렌더링', () => {
    render(<LoginForm />);

    expect(screen.getByLabelText('이메일')).toBeInTheDocument();
    expect(screen.getByLabelText('비밀번호')).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '로그인' })).toBeInTheDocument();
  });

  it('유효하지 않은 이메일 입력 시 에러', async () => {
    render(<LoginForm />);

    const emailInput = screen.getByLabelText('이메일');
    const submitButton = screen.getByRole('button', { name: '로그인' });

    fireEvent.change(emailInput, { target: { value: 'invalid-email' } });
    fireEvent.click(submitButton);

    await waitFor(() => {
      expect(screen.getByText('올바른 이메일을 입력하세요')).toBeInTheDocument();
    });
  });

  it('로그인 성공', async () => {
    const onLogin = jest.fn();
    render(<LoginForm onLogin={onLogin} />);

    fireEvent.change(screen.getByLabelText('이메일'), {
      target: { value: 'test@example.com' },
    });
    fireEvent.change(screen.getByLabelText('비밀번호'), {
      target: { value: 'password123' },
    });
    fireEvent.click(screen.getByRole('button', { name: '로그인' }));

    await waitFor(() => {
      expect(onLogin).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123',
      });
    });
  });
});

TDD (Test-Driven Development)

Red → Green → Refactor

typescript
// 1. RED: 실패하는 테스트 작성
describe('주문 총액 계산', () => {
  it('상품 가격 + 배송비', () => {
    const order = {
      items: [
        { price: 10000, quantity: 2 },
        { price: 5000, quantity: 1 },
      ],
      deliveryFee: 3000,
    };

    expect(calculateTotal(order)).toBe(28000);
  });
});

// 2. GREEN: 최소한의 코드로 테스트 통과
function calculateTotal(order: Order): number {
  const itemsTotal = order.items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
  return itemsTotal + order.deliveryFee;
}

// 3. REFACTOR: 코드 개선
function calculateTotal(order: Order): number {
  return calculateItemsTotal(order.items) + order.deliveryFee;
}

function calculateItemsTotal(items: OrderItem[]): number {
  return items.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );
}

테스트 커버리지

의미 있는 커버리지

bash
# Jest 커버리지
npm test -- --coverage

# 결과
--------------------|---------|----------|---------|---------|
File                | % Stmts | % Branch | % Funcs | % Lines |
--------------------|---------|----------|---------|---------|
All files           |   85.23 |    78.45 |   90.12 |   85.67 |
 services/          |   92.31 |    85.71 |   95.00 |   92.00 |
  payment.ts        |   95.00 |    90.00 |  100.00 |   95.00 |
  order.ts          |   90.00 |    82.00 |   90.00 |   89.00 |
--------------------|---------|----------|---------|---------|

⚠️ 100% 커버리지의 함정

typescript
// ❌ 100% 커버리지지만 의미 없는 테스트
it('함수 호출', () => {
  calculateDiscount(10000, 10);
  // expect 없음!
});

// ✅ 의미 있는 테스트
it('할인 계산', () => {
  const result = calculateDiscount(10000, 10);
  expect(result).toBe(9000);
});

// ✅ 엣지 케이스 테스트
it('음수 입력', () => {
  expect(() => calculateDiscount(-10000, 10)).toThrow();
});

실전 팁

1. AAA 패턴

typescript
describe('사용자 생성', () => {
  it('이메일 중복 시 에러', async () => {
    // Arrange: 준비
    await createUser({ email: 'test@example.com', name: '홍길동' });

    // Act: 실행
    const promise = createUser({ email: 'test@example.com', name: '김철수' });

    // Assert: 검증
    await expect(promise).rejects.toThrow('이미 존재하는 이메일입니다');
  });
});

2. 테스트 격리

typescript
// ❌ 테스트 간 의존성
let user: User;

it('사용자 생성', async () => {
  user = await createUser({ email: 'test@example.com' });
});

it('사용자 조회', async () => {
  const found = await getUser(user.id);  // 이전 테스트에 의존
  expect(found).toBeDefined();
});

// ✅ 각 테스트 독립적
describe('사용자 조회', () => {
  it('존재하는 사용자', async () => {
    const user = await createUser({ email: 'test@example.com' });
    const found = await getUser(user.id);
    expect(found).toBeDefined();
  });
});

3. 의미 있는 테스트 이름

typescript
// ❌ 모호한 이름
it('테스트1', () => { ... });
it('작동함', () => { ... });

// ✅ 명확한 이름
it('유효하지 않은 이메일 입력 시 에러를 반환한다', () => { ... });
it('재고가 부족하면 주문을 거부한다', () => { ... });

테스트 환경 설정

Jest 설정

javascript
// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src', '<rootDir>/tests'],
  testMatch: ['**/__tests__/**/*.ts', '**/*.test.ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/**/*.test.ts',
  ],
  coverageThreshold: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80,
    },
  },
};

package.json 스크립트

json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage",
    "test:unit": "jest --testMatch='**/*.test.ts'",
    "test:e2e": "playwright test"
  }
}

체크리스트

테스트 작성 전

  • 이 코드는 테스트할 가치가 있는가?
  • 핵심 비즈니스 로직인가?
  • 자주 버그가 발생하는 부분인가?

테스트 작성 시

  • 테스트 이름이 명확한가?
  • AAA 패턴을 따르는가?
  • 하나의 테스트에 하나의 검증만?
  • 엣지 케이스를 고려했는가?

PR 전

  • 모든 테스트 통과?
  • 새 기능에 테스트 추가?
  • 커버리지 감소하지 않음?

마무리 원칙

"좋은 테스트는 문서다. 코드가 무엇을 하는지 설명한다."

  • 의미 있는 테스트: 커버리지보다 품질
  • 빠른 피드백: 단위 테스트 중심
  • 신뢰할 수 있는 테스트: Flaky test 제거
  • 유지보수 가능한 테스트: 코드처럼 리팩토링
  • 자신감: 리팩토링과 배포의 안전망