단위 테스트 작성
단위 테스트는 개별 함수나 컴포넌트가 예상대로 동작하는지 검증합니다. Claude Code는 테스트 코드 작성을 자동화하여 시간을 절약하고, 엣지 케이스까지 고려한 포괄적인 테스트 스위트를 생성합니다. Jest, Vitest, Mocha 등 다양한 테스트 프레임워크를 지원합니다.
기본 단위 테스트 구조
// sum.ts
export function sum(a: number, b: number): number {
return a + b;
}
// sum.test.ts
import { describe, it, expect } from 'vitest';
import { sum } from './sum';
describe('sum', () => {
it('adds two positive numbers', () => {
expect(sum(2, 3)).toBe(5);
});
it('adds negative numbers', () => {
expect(sum(-2, -3)).toBe(-5);
});
it('adds zero', () => {
expect(sum(0, 5)).toBe(5);
});
it('handles decimals', () => {
expect(sum(0.1, 0.2)).toBeCloseTo(0.3);
});
});함수 테스트
// 테스트할 함수
function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
// 테스트 코드
describe('validateEmail', () => {
it('accepts valid emails', () => {
expect(validateEmail('user@example.com')).toBe(true);
expect(validateEmail('test.name@domain.co.uk')).toBe(true);
});
it('rejects invalid emails', () => {
expect(validateEmail('invalid')).toBe(false);
expect(validateEmail('@example.com')).toBe(false);
expect(validateEmail('user@')).toBe(false);
});
it('handles empty string', () => {
expect(validateEmail('')).toBe(false);
});
});React 컴포넌트 테스트
// Button.tsx
export function Button({ onClick, label }) {
return <button onClick={onClick}>{label}</button>;
}
// Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
describe('Button', () => {
it('renders with label', () => {
render(<Button label="Click me" onClick={() => {}} />);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('calls onClick when clicked', () => {
const handleClick = vi.fn();
render(<Button label="Click" onClick={handleClick} />);
fireEvent.click(screen.getByText('Click'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
});비동기 함수 테스트
// 비동기 함수
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
return response.json();
}
// 테스트 코드
describe('fetchUser', () => {
it('fetches user successfully', async () => {
// fetch를 모킹
global.fetch = vi.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ id: '1', name: 'John' })
})
) as any;
const user = await fetchUser('1');
expect(user).toEqual({ id: '1', name: 'John' });
expect(fetch).toHaveBeenCalledWith('/api/users/1');
});
it('handles errors', async () => {
global.fetch = vi.fn(() => Promise.reject(new Error('Network error')));
await expect(fetchUser('1')).rejects.toThrow('Network error');
});
});- 🎯격리된 테스트 - 각 함수를 독립적으로 테스트
- ⚡빠른 실행 - 외부 의존성 없이 순식간에 실행
- 🔄자동화 - CI/CD 파이프라인에서 자동 실행
- 📊즉각적 피드백 - 코드 변경 시 바로 결과 확인
- 🛡️회귀 방지 - 기존 기능이 깨지지 않았는지 검증
통합 테스트
통합 테스트는 여러 모듈이 함께 동작할 때 올바르게 작동하는지 검증합니다. 단위 테스트가 개별 부품을 테스트한다면, 통합 테스트는 조립된 시스템을 테스트합니다. Claude Code는 API 엔드포인트, 데이터베이스 연동, 서비스 간 통신 등 실제 환경과 유사한 조건에서 테스트 코드를 생성합니다.
API 엔드포인트 통합 테스트
// Express 앱 테스트
import request from 'supertest';
import { app } from './app';
import { db } from './database';
describe('User API', () => {
beforeAll(async () => {
// 테스트 DB 연결
await db.connect();
});
afterAll(async () => {
// DB 정리 및 연결 해제
await db.clearAll();
await db.disconnect();
});
beforeEach(async () => {
// 각 테스트 전 데이터 초기화
await db.users.deleteMany({});
});
describe('POST /api/users', () => {
it('creates a new user', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
name: 'Test User',
password: 'password123'
})
.expect(201);
expect(response.body).toMatchObject({
email: 'test@example.com',
name: 'Test User'
});
expect(response.body).not.toHaveProperty('password');
// DB에 실제로 저장되었는지 확인
const user = await db.users.findOne({ email: 'test@example.com' });
expect(user).toBeTruthy();
});
it('rejects duplicate emails', async () => {
// 첫 번째 사용자 생성
await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'User 1', password: 'pass' });
// 같은 이메일로 다시 시도
await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'User 2', password: 'pass' })
.expect(409); // Conflict
});
});
describe('GET /api/users/:id', () => {
it('returns user by id', async () => {
// 사용자 생성
const createRes = await request(app)
.post('/api/users')
.send({ email: 'test@example.com', name: 'Test', password: 'pass' });
const userId = createRes.body.id;
// 사용자 조회
const response = await request(app)
.get(`/api/users/${userId}`)
.expect(200);
expect(response.body.id).toBe(userId);
expect(response.body.email).toBe('test@example.com');
});
it('returns 404 for non-existent user', async () => {
await request(app)
.get('/api/users/nonexistent-id')
.expect(404);
});
});
});데이터베이스 통합
// 실제 DB 작업 테스트
describe('UserRepository', () => {
let repository: UserRepository;
beforeAll(async () => {
// 테스트 DB 초기화
await setupTestDatabase();
repository = new UserRepository();
});
it('saves and retrieves users', async () => {
const user = {
email: 'test@example.com',
name: 'Test User'
};
// 저장
const saved = await repository.save(user);
expect(saved.id).toBeDefined();
// 조회
const retrieved = await repository.findById(saved.id);
expect(retrieved).toMatchObject(user);
});
it('handles transactions', async () => {
await repository.transaction(async (tx) => {
await tx.users.create({ email: 'user1@test.com' });
await tx.users.create({ email: 'user2@test.com' });
});
const users = await repository.findAll();
expect(users).toHaveLength(2);
});
});서비스 간 통합
// 여러 서비스의 조합 테스트
describe('Order Processing', () => {
it('processes complete order flow', async () => {
// 1. 사용자 생성
const user = await userService.create({
email: 'buyer@test.com'
});
// 2. 상품 추가
const product = await productService.create({
name: 'Test Product',
price: 99.99
});
// 3. 주문 생성
const order = await orderService.create({
userId: user.id,
items: [{ productId: product.id, quantity: 2 }]
});
// 4. 결제 처리
const payment = await paymentService.process({
orderId: order.id,
amount: 199.98
});
// 5. 모든 단계 검증
expect(payment.status).toBe('completed');
expect(order.status).toBe('paid');
// 6. 이메일 발송 확인
expect(emailService.sent).toContainEqual(
expect.objectContaining({
to: 'buyer@test.com',
subject: expect.stringContaining('주문 확인')
})
);
});
});테스트 컨테이너 활용
// Docker 컨테이너로 실제 환경 구성
import { GenericContainer } from 'testcontainers';
describe('Redis Integration', () => {
let redisContainer;
let redisClient;
beforeAll(async () => {
// Redis 컨테이너 시작
redisContainer = await new GenericContainer('redis')
.withExposedPorts(6379)
.start();
const host = redisContainer.getHost();
const port = redisContainer.getMappedPort(6379);
redisClient = createRedisClient({ host, port });
}, 60000); // 컨테이너 시작에 시간이 걸림
afterAll(async () => {
await redisClient.quit();
await redisContainer.stop();
});
it('stores and retrieves data from Redis', async () => {
await redisClient.set('key', 'value');
const result = await redisClient.get('key');
expect(result).toBe('value');
});
});- 🔗실제 통합 - 모킹 대신 실제 서비스 간 통신 테스트
- 🗄️DB 연동 - 실제 데이터베이스로 쿼리와 트랜잭션 검증
- 🌐API 테스트 - HTTP 엔드포인트의 전체 요청/응답 사이클
- 🐳컨테이너화 - Docker로 독립적인 테스트 환경 구성
- 📦전체 플로우 - 여러 컴포넌트가 협력하는 시나리오
End-to-End 테스트
E2E 테스트는 실제 사용자의 관점에서 애플리케이션 전체를 테스트합니다. 브라우저를 자동으로 제어하여 클릭, 입력, 네비게이션 등 사용자 시나리오를 재현하고 기대하는 결과가 나오는지 검증합니다. Claude Code는 Playwright, Cypress 등을 활용하여 안정적이고 유지보수하기 쉬운 E2E 테스트를 생성합니다.
Playwright E2E 테스트
import { test, expect } from '@playwright/test';
test.describe('User Authentication', () => {
test('allows user to log in', async ({ page }) => {
// 로그인 페이지로 이동
await page.goto('http://localhost:3000/login');
// 폼 입력
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'password123');
// 로그인 버튼 클릭
await page.click('button[type="submit"]');
// 대시보드로 리다이렉트 확인
await expect(page).toHaveURL('http://localhost:3000/dashboard');
// 환영 메시지 확인
await expect(page.locator('h1')).toContainText('환영합니다');
});
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('http://localhost:3000/login');
await page.fill('input[name="email"]', 'wrong@example.com');
await page.fill('input[name="password"]', 'wrongpass');
await page.click('button[type="submit"]');
// 에러 메시지 표시 확인
await expect(page.locator('.error-message')).toContainText(
'이메일 또는 비밀번호가 올바르지 않습니다'
);
// 여전히 로그인 페이지에 있는지 확인
await expect(page).toHaveURL('http://localhost:3000/login');
});
});
test.describe('Shopping Cart', () => {
test.beforeEach(async ({ page }) => {
// 각 테스트 전에 로그인
await page.goto('http://localhost:3000/login');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await page.waitForURL('http://localhost:3000/dashboard');
});
test('adds items to cart and checks out', async ({ page }) => {
// 상품 페이지로 이동
await page.goto('http://localhost:3000/products');
// 첫 번째 상품을 장바구니에 추가
await page.click('button:has-text("장바구니에 추가"):first');
// 장바구니 아이콘의 뱃지 확인
await expect(page.locator('.cart-badge')).toHaveText('1');
// 장바구니 페이지로 이동
await page.click('a[href="/cart"]');
// 상품이 장바구니에 있는지 확인
await expect(page.locator('.cart-item')).toHaveCount(1);
// 체크아웃
await page.click('button:has-text("결제하기")');
// 결제 완료 페이지 확인
await expect(page.locator('h1')).toContainText('주문이 완료되었습니다');
});
});시각적 회귀 테스트
// 스크린샷 비교로 UI 변경 감지
test('homepage looks correct', async ({ page }) => {
await page.goto('http://localhost:3000');
// 스크린샷 찍고 이전과 비교
await expect(page).toHaveScreenshot('homepage.png');
});
test('responsive design on mobile', async ({ page }) => {
// 모바일 뷰포트 설정
await page.setViewportSize({ width: 375, height: 667 });
await page.goto('http://localhost:3000');
await expect(page).toHaveScreenshot('homepage-mobile.png');
});네트워크 인터셉트
// API 응답 모킹
test('handles API errors gracefully', async ({ page }) => {
// API 요청 가로채기
await page.route('**/api/users', (route) => {
route.fulfill({
status: 500,
body: JSON.stringify({ error: 'Server Error' })
});
});
await page.goto('http://localhost:3000/users');
// 에러 메시지 표시 확인
await expect(page.locator('.error')).toContainText(
'서버 오류가 발생했습니다'
);
});- 🌐실제 브라우저 - Chrome, Firefox, Safari에서 실행
- 👤사용자 시나리오 - 실제 사용자 행동 패턴 재현
- 📸시각적 테스트 - 스크린샷 비교로 UI 회귀 방지
- 🔄크로스 브라우저 - 여러 브라우저에서 호환성 검증
- 📱반응형 테스트 - 다양한 디바이스 크기 검증
테스트 커버리지
테스트 커버리지는 코드의 어느 부분이 테스트되었는지 측정합니다. 높은 커버리지가 품질을 보장하지는 않지만, 테스트되지 않은 코드를 찾는 데 유용합니다. Claude Code는 커버리지 리포트를 분석하여 중요하지만 테스트되지 않은 영역을 식별하고 해당 부분의 테스트 코드를 생성합니다.
커버리지 측정 설정
// package.json
{
"scripts": {
"test": "vitest",
"test:coverage": "vitest --coverage"
},
"devDependencies": {
"@vitest/coverage-v8": "^1.0.0"
}
}
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json'],
exclude: [
'node_modules/',
'dist/',
'**/*.test.ts',
'**/*.config.ts'
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80
}
}
}
});
// 커버리지 실행
$ npm run test:coverage
// 결과
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Lines
----------------|---------|----------|---------|---------|----------------
All files | 85.2 | 78.4 | 90.1 | 84.8 |
utils/ | 92.3 | 87.5 | 100 | 91.7 |
validation.ts | 95.0 | 90.0 | 100 | 94.4 | 45-47
formatting.ts | 88.9 | 83.3 | 100 | 87.5 | 23,67
services/ | 78.1 | 69.3 | 80.4 | 77.9 |
user.ts | 72.5 | 60.0 | 75.0 | 71.8 | 89-105,120-135커버리지 메트릭
실행된 코드 라인의 비율
if/else 등 모든 분기의 실행 비율
호출된 함수의 비율
실행된 구문의 비율
커버리지 개선 전략
비즈니스 로직과 복잡한 알고리즘 먼저 테스트
커버되지 않은 분기는 보통 예외 처리
try-catch, 유효성 검증 실패 경로
80-90%가 실용적인 목표
커버되지 않은 코드 테스트
// 커버리지 리포트에서 89-105 라인이 테스트 안 됨
function processPayment(amount: number, method: string) {
if (amount <= 0) {
throw new Error('Invalid amount');
}
if (method === 'credit') {
return processCreditCard(amount);
} else if (method === 'paypal') {
return processPayPal(amount); // ❌ 테스트 안 됨 (89-105)
} else {
throw new Error('Unsupported method'); // ❌ 테스트 안 됨
}
}
// 테스트 추가로 커버리지 개선
describe('processPayment', () => {
// 기존 테스트들...
it('processes PayPal payments', () => { // ✅ 새로 추가
const result = processPayment(100, 'paypal');
expect(result.method).toBe('paypal');
});
it('rejects unsupported payment methods', () => { // ✅ 새로 추가
expect(() => processPayment(100, 'bitcoin'))
.toThrow('Unsupported method');
});
});
// 커버리지: 95% → 100% 🎉- 📊시각적 리포트 - HTML 리포트로 커버되지 않은 라인 확인
- 🎯목표 설정 - 프로젝트별 커버리지 임계값 지정
- 🔍갭 식별 - 테스트되지 않은 중요 코드 발견
- 📈추세 추적 - 커버리지 변화 모니터링
- 🚫배제 설정 - 테스트 불필요한 파일 제외
CI/CD 테스트 자동화
모든 코드 변경마다 자동으로 테스트를 실행하면 버그를 조기에 발견할 수 있습니다. CI/CD 파이프라인에 테스트를 통합하여 품질 게이트를 설정하고, 테스트가 실패하면 배포를 자동으로 차단합니다. Claude Code는 GitHub Actions, GitLab CI, Jenkins 등 다양한 CI/CD 도구 설정을 지원합니다.
GitHub Actions CI/CD
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run linter
run: npm run lint
- name: Run type check
run: npm run type-check
- name: Run tests
run: npm test -- --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage/coverage-final.json
- name: Build
run: npm run build
e2e:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Install dependencies
run: npm ci
- name: Install Playwright
run: npx playwright install --with-deps
- name: Run E2E tests
run: npm run test:e2e
- name: Upload test results
uses: actions/upload-artifact@v3
if: always()
with:
name: playwright-report
path: playwright-report/Pull Request 보호
# 테스트 통과 필수
# Settings → Branches → Add rule
Branch protection rules:
☑ Require status checks to pass before merging
☑ Tests (ubuntu-latest, 18.x)
☑ Tests (ubuntu-latest, 20.x)
☑ E2E tests
☑ codecov/patch
☑ codecov/project
☑ Require branches to be up to date
# 테스트 실패 시 merge 불가 ⛔자동 배포 파이프라인
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test:
# ... 테스트 실행
deploy:
needs: test # 테스트 통과 후에만 배포
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Deploy to production
run: |
npm run build
npm run deploy
env:
API_KEY: ${{ secrets.API_KEY }}테스트 병렬화로 속도 향상
# Vitest 병렬 실행 설정
// vitest.config.ts
export default defineConfig({
test: {
pool: 'threads',
poolOptions: {
threads: {
singleThread: false,
maxThreads: 4
}
}
}
});
# GitHub Actions 매트릭스로 병렬화
jobs:
test:
strategy:
matrix:
shard: [1, 2, 3, 4]
steps:
- run: npm test -- --shard=${{ matrix.shard }}/4
# 실행 시간: 8분 → 2분 ⚡- 🔄자동 실행 - 코드 푸시마다 자동으로 테스트
- 🛡️품질 게이트 - 테스트 실패 시 배포 차단
- ⚡병렬 실행 - 여러 환경에서 동시에 테스트
- 📊리포팅 - 커버리지와 테스트 결과 시각화
- 🔔알림 - 실패 시 팀에게 즉시 통지
Mock과 Stub
테스트 시 외부 의존성(API, 데이터베이스, 파일 시스템)을 실제로 호출하면 테스트가 느리고 불안정해집니다. Mock과 Stub을 사용하여 의존성을 대체하고 테스트를 빠르고 안정적으로 만드세요. Claude Code는 적절한 모킹 전략을 제안하고 mock 객체를 자동으로 생성합니다.
함수 모킹 (Vitest/Jest)
// 테스트할 함수
import { sendEmail } from './email';
import { logEvent } from './analytics';
export async function registerUser(userData) {
const user = await db.users.create(userData);
await sendEmail(user.email, 'welcome');
logEvent('user_registered', { userId: user.id });
return user;
}
// 테스트 코드
import { vi } from 'vitest';
import { sendEmail } from './email';
import { logEvent } from './analytics';
// 모듈 전체 모킹
vi.mock('./email');
vi.mock('./analytics');
describe('registerUser', () => {
it('sends welcome email and logs event', async () => {
const userData = { email: 'test@example.com', name: 'Test' };
const user = await registerUser(userData);
// 함수가 올바른 인자로 호출되었는지 확인
expect(sendEmail).toHaveBeenCalledWith('test@example.com', 'welcome');
expect(logEvent).toHaveBeenCalledWith('user_registered', {
userId: user.id
});
});
it('handles email sending errors', async () => {
// 특정 동작 모킹
vi.mocked(sendEmail).mockRejectedValue(new Error('Email service down'));
const userData = { email: 'test@example.com' };
// 에러가 전파되지 않고 처리되는지 확인
await expect(registerUser(userData)).resolves.toBeDefined();
});
});API 응답 모킹
// API 호출 함수
async function fetchUserData(userId: string) {
const res = await fetch(`/api/users/${userId}`);
return res.json();
}
// 테스트
import { vi } from 'vitest';
describe('fetchUserData', () => {
it('fetches user successfully', async () => {
// fetch 모킹
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({
id: '123',
name: 'John Doe'
})
})
) as any;
const user = await fetchUserData('123');
expect(user.name).toBe('John Doe');
expect(fetch).toHaveBeenCalledWith('/api/users/123');
});
});클래스/객체 모킹
// 데이터베이스 클래스
class Database {
async query(sql: string) {
// 실제 DB 쿼리
}
}
// 테스트
describe('UserService', () => {
it('queries database correctly', async () => {
// Mock 데이터베이스
const mockDb = {
query: vi.fn().mockResolvedValue([
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' }
])
};
const service = new UserService(mockDb);
const users = await service.getAll();
expect(users).toHaveLength(2);
expect(mockDb.query).toHaveBeenCalledWith(
'SELECT * FROM users'
);
});
});스파이 (Spy) - 실제 함수 감시
// Mock: 함수 완전 대체
const mockFn = vi.fn(() => 'mocked');
mockFn(); // 'mocked'
// Spy: 실제 함수 유지하면서 호출 감시
const obj = {
method(x) {
return x * 2;
}
};
const spy = vi.spyOn(obj, 'method');
obj.method(5); // 10 (실제 함수 실행)
expect(spy).toHaveBeenCalledWith(5);
expect(spy).toHaveReturnedWith(10);
// 원하면 mock 구현으로 덮어쓰기 가능
spy.mockImplementation((x) => x * 3);
obj.method(5); // 15- 🎭의존성 격리 - 외부 서비스 없이 독립적 테스트
- ⚡속도 향상 - 네트워크/DB 호출 없이 즉시 실행
- 🎯호출 검증 - 함수가 올바른 인자로 호출되었는지 확인
- 🔄반복 가능 - 항상 같은 결과로 안정적
- 🧪엣지 케이스 - 에러 상황을 쉽게 시뮬레이션