테스트 주도 개발 (TDD)
테스트 주도 개발(Test-Driven Development, TDD)은 소프트웨어 개발 방법론 중 하나입니다.
한 줄로 요약하자면 실제 코드를 작성하기 전에 테스트 코드를 먼저 작성하는 방식입니다.
이 접근법은 코드의 품질을 향상시키고, 버그를 줄이며, 유지보수성을 높이는 데 기여합니다.
TDD의 기본 원칙
- 실패하는 테스트 작성 (Red)
- 테스트를 통과하는 최소한의 코드 작성 (Green)
- 코드 리팩토링 (Refactor)
이 사이클을 반복함으로써 점진적으로 기능을 개발하고 코드 품질을 개선합니다.
타입스크립트에서의 TDD 환경 설정
타입스크립트 프로젝트에서 TDD를 위한 기본 설정
npm init -y
npm install typescript ts-node @types/node --save-dev
npm install jest @types/jest ts-jest --save-dev
Jest 설정 (jest.config.js
)
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};
주요 테스팅 프레임워크 비교
- Jest : 설정이 간편하고, 빠른 실행 속도, 내장 모킹 기능 제공
- Mocha : 유연성이 높고, 다양한 어서션 라이브러리와 함께 사용 가능
- Jasmine : BDD 스타일의 문법, 내장 어서션과 모킹 기능 제공
Jest가 타입스크립트와의 통합이 쉽고 사용이 간편하여 많이 사용됩니다.
단위 테스트 작성
타입스크립트의 타입 시스템을 활용한 테스트 케이스 설계
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
// math.test.ts
import { add } from './math';
describe('add function', () => {
it('should add two numbers correctly', () => {
expect(add(2, 3)).toBe(5);
});
it('should handle negative numbers', () => {
expect(add(-1, 1)).toBe(0);
});
// 타입 체크 테스트
it('should not allow strings', () => {
// @ts-expect-error
add('2', '3');
});
});
목(Mock) 객체와 스텁(Stub) 사용
의존성 격리를 위한 목 객체 사용
// user-service.ts
import { Database } from './database';
export class UserService {
constructor(private db: Database) {}
async getUser(id: string): Promise<User> {
return this.db.findUser(id);
}
}
// user-service.test.ts
describe('UserService', () => {
it('should get user by id', async () => {
const mockDb = {
findUser: jest.fn().mockResolvedValue({ id: '1', name: 'John' })
};
const userService = new UserService(mockDb as unknown as Database);
const user = await userService.getUser('1');
expect(user.name).toBe('John');
expect(mockDb.findUser).toHaveBeenCalledWith('1');
});
});
통합 테스트와 E2E 테스트
통합 테스트 예시 (Express 애플리케이션)
import request from 'supertest';
import { app } from './app';
describe('User API', () => {
it('should create a new user', async () => {
const res = await request(app)
.post('/users')
.send({ name: 'Alice', email: 'alice@example.com' });
expect(res.status).toBe(201);
expect(res.body).toHaveProperty('id');
});
});
E2E 테스트는 Cypress나 Puppeteer와 같은 도구를 사용하여 실제 브라우저 환경에서 테스트를 수행합니다.
비동기 코드 테스트
프로미스 기반 함수 테스트
// async-function.ts
export async function fetchData(): Promise<string> {
return new Promise(resolve => setTimeout(() => resolve('data'), 100));
}
// async-function.test.ts
describe('fetchData', () => {
it('should return data after delay', async () => {
const result = await fetchData();
expect(result).toBe('data');
});
it('should handle errors', async () => {
await expect(fetchData()).rejects.toThrow();
});
});
테스트 커버리지 측정
Jest를 사용한 커버리지 측정
{
"scripts": {
"test": "jest --coverage"
},
"jest": {
"coverageThreshold": {
"global": {
"branches": 80,
"functions": 80,
"lines": 80,
"statements": 80
}
}
}
}
이 설정은 80% 이상의 커버리지를 요구합니다.
TDD를 적용한 리팩토링
리팩토링 예시
- 기존 코드에 대한 테스트 작성
- 리팩토링 수행
- 테스트 실행으로 기능 보장
// Before
function getFullName(user: { firstName: string, lastName: string }): string {
return user.firstName + ' ' + user.lastName;
}
// Test
describe('getFullName', () => {
it('should return full name', () => {
expect(getFullName({ firstName: 'John', lastName: 'Doe' })).toBe('John Doe');
});
});
// After (리팩토링)
function getFullName(user: { firstName: string, lastName: string }): string {
return `${user.firstName} ${user.lastName}`.trim();
}
타입 정의 테스트
타입 정의 파일(.d.ts)에 대한 테스트
// types.d.ts
declare function greet(name: string): string;
// types.test-d.ts
import { expectType } from 'tsd';
expectType<string>(greet('Alice'));
// @ts-expect-error
greet(123);
이 테스트는 tsd
라이브러리를 사용하여 타입 정의의 정확성을 검증합니다.
Best Practices와 TDD 문화 정착
- 작은 단위로 테스트 작성 : 각 테스트는 하나의 동작만 검증
- 테스트 가능한 코드 설계 : 의존성 주입, 단일 책임 원칙 준수
- 지속적인 통합(CI) 파이프라인에 테스트 포함
- 테스트 코드 리뷰 문화 정착
- 테스트 우선 개발 습관 형성
- 모든 버그에 대한 회귀 테스트 작성
- 테스트 코드의 품질도 중요하게 관리
- 정기적인 테스트 코드 리팩토링
- 팀 내 TDD 워크샵 및 교육 세션 진행
- 테스트 커버리지 목표 설정 및 모니터링