TDD 실전 후기: vitest로 TypeScript 테스팅하며 배운 것들
핵심 요약
- 전환: 테스트 없는 개발 → TDD (Test-Driven Development)
- 도구: vitest + TypeScript (Jest보다 10배 빠름)
- 성과: 75개 테스트 작성, 버그 발견 90% 빠름, 리팩토링 자신감 확보
- 실전: WordPress 블로그 자동화 CLI 프로젝트 (검증 완료)
1. TDD를 시작하게 된 계기
프로젝트 초기에는 테스트 코드 없이 개발했습니다.
“일단 빠르게 만들고, 나중에 테스트 추가하자”
하지만 현실은 달랐습니다.
테스트 없는 개발의 고통
문제 1: 리팩토링 공포
// ❌ 리팩토링 후 뭐가 깨졌는지 모름
function validateTranslation(original, translated) {
// 100줄의 복잡한 로직...
// 수정했는데 제대로 작동하나? 🤔
}
문제 2: 버그 발견 지연
시간 경과 이벤트
Day 1 기능 구현 완료
Day 3 다른 기능 개발 중...
Day 7 사용자가 버그 발견 "번역이 이상해요"
Day 8 원인 찾기 시작 (어디서 깨졌지?)
Day 9 디버깅 완료 (총 2일 소요)
문제 3: 엣지 케이스 놓침
// ❌ 이런 케이스를 생각 못함
validateTranslation('', ''); // 빈 문자열
validateTranslation('한글', undefined); // undefined
validateTranslation(null, 'English'); // null
결정: “더 이상 이렇게 개발할 수 없다. TDD를 배우자.”
2. vitest를 선택한 이유
TypeScript 테스팅 도구는 많습니다. 왜 vitest인가?
2.1 주요 테스팅 프레임워크 비교
| 도구 | 속도 | ESM 지원 | TypeScript | 설정 복잡도 |
|---|---|---|---|---|
| vitest | ⚡⚡⚡ (10배) | ✅ 네이티브 | ✅ 완벽 | 🟢 간단 |
| Jest | 🐢 느림 | ⚠️ 실험적 | ✅ 좋음 | 🟡 보통 |
| Mocha | 🐢 느림 | ❌ 없음 | ⚠️ 수동 | 🔴 복잡 |
2.2 vitest의 결정적 장점
1. 압도적인 속도
# Jest
✓ 75 tests in 8.5s
# vitest
✓ 75 tests in 0.8s (10배 빠름!)
2. ESM 네이티브 지원
// ✅ vitest: 그냥 작동
import { validateTranslation } from './validation.js';
// ❌ Jest: 설정 지옥
// package.json에 "type": "module" 추가
// babel 설정, transform 설정...
3. TypeScript 완벽 지원
// ✅ 타입 자동 추론
import { describe, it, expect } from 'vitest';
describe('Validation', () => {
it('should validate', () => {
const result = validateTranslation(/* ... */);
// result의 타입이 자동으로 추론됨!
});
});
4. Jest 호환 API
// Jest 경험이 있다면 즉시 사용 가능
describe, it, expect, beforeEach, afterEach
vi.mock(), vi.spyOn() // Jest의 jest.mock()과 동일
3. vitest 설정 및 첫 테스트
3.1 설치 및 설정
# 1. vitest 설치
pnpm add -D vitest
# 2. package.json에 스크립트 추가
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
}
}
vitest.config.ts (선택사항):
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true, // describe, it, expect를 import 없이 사용
environment: 'node',
},
});
3.2 첫 번째 테스트 작성
테스트 대상 (packages/core/src/validation.ts):
export function validateBasics(content: string): boolean {
if (!content || content.trim().length === 0) {
return false;
}
return true;
}
테스트 코드 (packages/core/src/validation.test.ts):
import { describe, it, expect } from 'vitest';
import { validateBasics } from './validation';
describe('validateBasics', () => {
it('should return false for empty content', () => {
const result = validateBasics('');
expect(result).toBe(false);
});
it('should return false for whitespace only', () => {
const result = validateBasics(' ');
expect(result).toBe(false);
});
it('should return true for valid content', () => {
const result = validateBasics('Hello World');
expect(result).toBe(true);
});
});
실행:
pnpm test
# 결과
✓ src/validation.test.ts (3 tests) 5ms
✓ validateBasics (3)
✓ should return false for empty content
✓ should return false for whitespace only
✓ should return true for valid content
Test Files 1 passed (1)
Tests 3 passed (3)
4. TDD 워크플로우 실전: Red-Green-Refactor
실제 프로젝트에서 validateTranslation 함수를 TDD로 개발한 과정입니다.
4.1 Red: 실패하는 테스트 작성
요구사항: “번역된 콘텐츠가 원본과 라인 수가 50-150% 범위 내에 있어야 함”
// validation.test.ts
describe('validateLineCount', () => {
it('should pass if line count is within 50-150% range', () => {
const original = 'Line 1\nLine 2\nLine 3\nLine 4'; // 4 lines
const translated = 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5'; // 5 lines (125%)
const result = validateLineCount(original, translated);
expect(result.isValid).toBe(true);
expect(result.issues).toHaveLength(0);
});
it('should fail if line count is too low (<50%)', () => {
const original = 'Line 1\nLine 2\nLine 3\nLine 4'; // 4 lines
const translated = 'Line 1'; // 1 line (25%)
const result = validateLineCount(original, translated);
expect(result.isValid).toBe(false);
expect(result.issues).toContainEqual({
severity: 'error',
message: expect.stringContaining('Line count too low')
});
});
});
실행 결과:
✗ validateLineCount > should pass if line count is within 50-150% range
ReferenceError: validateLineCount is not defined
🔴 Red: 테스트 실패 (함수가 없음)
4.2 Green: 최소한의 코드로 테스트 통과
// validation.ts
export function validateLineCount(
original: string,
translated: string
): ValidationResult {
const originalLines = original.split('\n').length;
const translatedLines = translated.split('\n').length;
const ratio = translatedLines / originalLines;
const issues: ValidationIssue[] = [];
if (ratio < 0.5) {
issues.push({
severity: 'error',
message: `Line count too low: ${translatedLines} lines (${(ratio * 100).toFixed(1)}% of original ${originalLines} lines)`
});
}
if (ratio > 1.5) {
issues.push({
severity: 'error',
message: `Line count too high: ${translatedLines} lines (${(ratio * 100).toFixed(1)}% of original ${originalLines} lines)`
});
}
return {
isValid: issues.length === 0,
issues
};
}
실행 결과:
✓ validateLineCount (2 tests) 3ms
✓ should pass if line count is within 50-150% range
✓ should fail if line count is too low (<50%)
🟢 Green: 테스트 통과!
4.3 Refactor: 코드 개선
// validation.ts (리팩토링)
const LINE_COUNT_MIN_RATIO = 0.5;
const LINE_COUNT_MAX_RATIO = 1.5;
export function validateLineCount(
original: string,
translated: string
): ValidationResult {
const originalLines = countLines(original);
const translatedLines = countLines(translated);
const ratio = translatedLines / originalLines;
const issues = [
...checkLineCountTooLow(ratio, originalLines, translatedLines),
...checkLineCountTooHigh(ratio, originalLines, translatedLines),
];
return {
isValid: issues.length === 0,
issues
};
}
// 헬퍼 함수
function countLines(content: string): number {
return content.split('\n').length;
}
function checkLineCountTooLow(
ratio: number,
originalLines: number,
translatedLines: number
): ValidationIssue[] {
if (ratio >= LINE_COUNT_MIN_RATIO) return [];
return [{
severity: 'error',
message: `Line count too low: ${translatedLines} lines (${(ratio * 100).toFixed(1)}% of original ${originalLines} lines)`
}];
}
function checkLineCountTooHigh(
ratio: number,
originalLines: number,
translatedLines: number
): ValidationIssue[] {
if (ratio <= LINE_COUNT_MAX_RATIO) return [];
return [{
severity: 'error',
message: `Line count too high: ${translatedLines} lines (${(ratio * 100).toFixed(1)}% of original ${originalLines} lines)`
}];
}
테스트 재실행:
✓ validateLineCount (2 tests) 2ms
✅ Refactor: 테스트는 여전히 통과, 코드는 더 깔끔함!
5. 고급 테스팅 패턴
5.1 비동기 함수 테스트
// translator.ts
export async function translatePost(
content: string
): Promise<string> {
const response = await fetch('https://api.claude.ai/translate', {
method: 'POST',
body: JSON.stringify({ content })
});
return response.json();
}
// translator.test.ts
import { describe, it, expect, vi } from 'vitest';
describe('translatePost', () => {
it('should translate content via API', async () => {
// ✅ async/await 사용
const result = await translatePost('안녕하세요');
expect(result).toContain('Hello');
});
});
5.2 Mocking (모의 객체)
// translator.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ✅ fetch를 mock
global.fetch = vi.fn();
describe('translatePost', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should call API with correct params', async () => {
// Mock 설정
(fetch as any).mockResolvedValue({
json: async () => 'Translated text'
});
await translatePost('Original text');
// API 호출 검증
expect(fetch).toHaveBeenCalledWith(
'https://api.claude.ai/translate',
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ content: 'Original text' })
})
);
});
it('should handle API errors', async () => {
// API 에러 mock
(fetch as any).mockRejectedValue(new Error('Network error'));
await expect(translatePost('text')).rejects.toThrow('Network error');
});
});
5.3 예외 처리 테스트
describe('validateTranslation', () => {
it('should throw error for invalid input', () => {
expect(() => {
validateTranslation(null as any, 'valid');
}).toThrow('Original content is required');
});
it('should return error for empty content', async () => {
const result = await validateTranslation('', '');
expect(result.isValid).toBe(false);
expect(result.issues).toContainEqual({
severity: 'error',
message: 'Content is empty'
});
});
});
6. Coverage 측정 및 품질 관리
6.1 Coverage 설정
# @vitest/coverage-v8 설치
pnpm add -D @vitest/coverage-v8
# Coverage 실행
pnpm test --coverage
결과:
% Coverage report from v8
-------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-------------------|---------|----------|---------|---------|
All files | 94.23 | 89.47 | 92.85 | 94.23 |
validation.ts | 100 | 100 | 100 | 100 |
translator.ts | 88.88 | 83.33 | 85.71 | 88.88 |
markdown.ts | 95.45 | 91.66 | 93.75 | 95.45 |
-------------------|---------|----------|---------|---------|
6.2 Coverage 목표
권장 기준:
- Statements: ≥80%
- Branches: ≥75%
- Functions: ≥80%
- Lines: ≥80%
프로젝트 실제 달성:
- ✅ Statements: 94.23%
- ✅ Branches: 89.47%
- ✅ Functions: 92.85%
- ✅ Lines: 94.23%
6.3 Coverage가 낮은 코드 찾기
# HTML 리포트 생성
pnpm test --coverage --reporter=html
# coverage/index.html 열기
open coverage/index.html
빨간색으로 표시된 라인 = 테스트되지 않은 코드
7. 실전 팁 및 함정
팁 1: 테스트명은 명확하게
// ❌ 나쁜 예
it('works', () => { ... });
it('test 1', () => { ... });
// ✅ 좋은 예
it('should return false for empty content', () => { ... });
it('should throw error when API key is missing', () => { ... });
팁 2: 테스트 격리 (beforeEach)
describe('WordPressClient', () => {
let client: WordPressClient;
// ✅ 각 테스트 전에 새 인스턴스 생성
beforeEach(() => {
client = new WordPressClient({
url: 'https://test.com',
username: 'test',
password: 'test'
});
});
it('should create post', async () => {
// client는 깨끗한 상태
});
it('should update post', async () => {
// 이전 테스트의 영향 없음
});
});
팁 3: 하나의 테스트에 하나의 검증
// ❌ 나쁜 예: 여러 검증
it('should do everything', () => {
expect(result.isValid).toBe(true);
expect(result.issues).toHaveLength(0);
expect(result.metrics.lineCountDiff).toBe(0);
expect(result.metrics.codeBlocks).toBe(3);
// ... 10개 더
});
// ✅ 좋은 예: 분리
it('should be valid', () => {
expect(result.isValid).toBe(true);
});
it('should have no issues', () => {
expect(result.issues).toHaveLength(0);
});
it('should preserve line count', () => {
expect(result.metrics.lineCountDiff).toBe(0);
});
함정 1: 테스트 순서 의존
// ❌ 나쁜 예
let sharedState = 0;
it('test 1', () => {
sharedState = 10;
});
it('test 2', () => {
// test 1이 먼저 실행된다고 가정 (위험!)
expect(sharedState).toBe(10);
});
해결: 각 테스트는 독립적이어야 함 (beforeEach 사용)
함정 2: 너무 많은 Mock
// ❌ 나쁜 예: 모든 걸 mock
vi.mock('./file1');
vi.mock('./file2');
vi.mock('./file3');
// ... 10개 더
// ✅ 좋은 예: 필요한 것만 mock (외부 API, 파일 시스템 등)
vi.mock('node:fs');
global.fetch = vi.fn();
8. 실전 성과 측정
8.1 측정 가능한 성과
| 지표 | Before TDD | After TDD | 개선율 |
|---|---|---|---|
| 버그 발견 | 7일 후 (사용자 발견) | 즉시 (테스트 실패) | 90% 빠름 |
| 리팩토링 시간 | 2시간 (불안) | 30분 (자신감) | 75% 단축 |
| 디버깅 시간 | 평균 4시간 | 평균 30분 | 87% 단축 |
| 코드 커버리지 | 0% | 94% | +94%p |
8.2 실제 사례
사례 1: 번역 검증 버그
Before TDD:
- Day 1: 번역 기능 구현 완료
- Day 5: 사용자 "코드 블록이 사라졌어요"
- Day 6: 원인 찾기 시작
- Day 7: 수정 완료 (총 3일 소요)
After TDD:
- Day 1: 테스트 작성 → 코드 블록 보존 검증
- Day 1: 구현 → 테스트 통과
- 버그 0건 (테스트로 사전 차단)
사례 2: 리팩토링 자신감
Before TDD:
- "이 함수 수정하면 뭐가 깨질까?" 😰
- 수정 후 전체 앱 수동 테스트 (2시간)
After TDD:
- "pnpm test 실행" 😎
- 75개 테스트 통과 (30초)
- 자신 있게 배포
9. 결론
핵심 요약
TDD의 가치:
- ✅ 버그 발견 90% 빠름 (사용자 발견 → 테스트 실패로 즉시 감지)
- ✅ 리팩토링 자신감 (테스트가 안전망)
- ✅ 문서화 효과 (테스트 = 실행 가능한 예제)
- ✅ 설계 개선 (테스트하기 쉬운 코드 = 좋은 설계)
vitest의 장점:
- ⚡ Jest보다 10배 빠름
- 🟢 TypeScript 완벽 지원
- 📦 ESM 네이티브
- 🔧 설정 간단
당신도 시작할 수 있습니다
최소 시작 (10분):
# 1. 설치
pnpm add -D vitest
# 2. 첫 테스트 작성
echo 'import { describe, it, expect } from "vitest";
describe("My Function", () => {
it("should work", () => {
expect(1 + 1).toBe(2);
});
});' > src/example.test.ts
# 3. 실행
pnpm vitest
# 완료! 이제 TDD 개발자입니다 🎉
다음 단계
이 패턴을 익혔다면, 다음 주제를 확인하세요:
- Day 3 예고: API 에러 핸들링 실전 가이드 (타임아웃, 재시도, Fallback)
- 관련 주제: TypeScript 에러 핸들링 베스트 프랙티스
마지막으로
“테스트는 시간 낭비가 아니라 시간 투자입니다.”
처음에는 느리지만, 장기적으로는 10배 빠른 개발 속도를 얻습니다.
테스트 없이 개발하는 것은 안전벨트 없이 운전하는 것과 같습니다.
vitest + TDD로 안전하고 자신 있는 개발을 시작하세요.
질문이나 피드백은 댓글로 남겨주세요!
실제 프로젝트 코드: GitHub Repository – 75개 테스트, 94% 커버리지
Leave A Comment