icon안동민 개발노트

테스트 주도 개발 (TDD)


 테스트 주도 개발(Test-Driven Development, TDD)은 소프트웨어 개발 방법론 중 하나입니다.

 한 줄로 요약하자면 실제 코드를 작성하기 전에 테스트 코드를 먼저 작성하는 방식입니다.

 이 접근법은 코드의 품질을 향상시키고, 버그를 줄이며, 유지보수성을 높이는 데 기여합니다.

TDD의 기본 원칙

  1. 실패하는 테스트 작성 (Red)
  2. 테스트를 통과하는 최소한의 코드 작성 (Green)
  3. 코드 리팩토링 (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',
};

주요 테스팅 프레임워크 비교

  1. Jest : 설정이 간편하고, 빠른 실행 속도, 내장 모킹 기능 제공
  2. Mocha : 유연성이 높고, 다양한 어서션 라이브러리와 함께 사용 가능
  3. 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: '[email protected]' });
 
        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를 적용한 리팩토링

 리팩토링 예시

  1. 기존 코드에 대한 테스트 작성
  2. 리팩토링 수행
  3. 테스트 실행으로 기능 보장
// 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 문화 정착

  1. 작은 단위로 테스트 작성 : 각 테스트는 하나의 동작만 검증
  2. 테스트 가능한 코드 설계 : 의존성 주입, 단일 책임 원칙 준수
  3. 지속적인 통합(CI) 파이프라인에 테스트 포함
  4. 테스트 코드 리뷰 문화 정착
  5. 테스트 우선 개발 습관 형성
  6. 모든 버그에 대한 회귀 테스트 작성
  7. 테스트 코드의 품질도 중요하게 관리
  8. 정기적인 테스트 코드 리팩토링
  9. 팀 내 TDD 워크샵 및 교육 세션 진행
  10. 테스트 커버리지 목표 설정 및 모니터링