icon
10장 : 객체지향 프로그래밍

테스트 주도 개발 (TDD)


테스트 주도 개발(Test-Driven Development, TDD) 은 소프트웨어 개발 방법론의 하나로, 코드를 작성하기 전에 실패하는 테스트 케이스를 먼저 작성하고, 그 테스트를 통과할 수 있는 최소한의 코드를 작성한 다음, 코드를 리팩토링하는 과정을 반복하는 개발 방식입니다. 단순히 테스트 코드를 작성하는 것을 넘어, 설계 프로세스의 핵심 부분으로 테스트를 활용하는 것입니다.

TDD는 Kent Beck이 XP(Extreme Programming)의 일환으로 주창했으며, 코드의 품질을 높이고 개발 생산성을 향상시키는 데 큰 기여를 합니다. 타입스크립트와 같은 강력한 타입 시스템을 가진 언어에서는 TDD를 통해 런타임 오류뿐만 아니라 타입 관련 오류까지도 더욱 견고하게 방지할 수 있습니다.


TDD의 세 가지 황금률

TDD는 다음의 세 가지 단계를 반복하는 사이클로 이루어집니다.

Red (빨강)

  • 실패하는 테스트를 먼저 작성합니다. 현재 구현되어 있지 않거나, 의도적으로 잘못된 동작을 테스트하는 코드를 작성합니다.
  • 이 테스트는 실행했을 때 반드시 실패해야 합니다. 실패하지 않는다면, 테스트 자체가 잘못되었거나, 이미 기능이 구현되어 있는 경우입니다.
  • 이 단계에서 작성하는 테스트는 개발하려는 기능의 명세를 정의하는 역할을 합니다.

Green (초록)

  • 테스트를 통과시킬 수 있는 최소한의 코드를 작성합니다.
  • 이 단계에서는 오직 테스트를 통과시키는 것에만 집중하고, 코드의 아름다움이나 일반화는 고려하지 않습니다. 가장 빠르고 간단한 방법으로 테스트를 통과시킵니다.
  • 테스트가 실행했을 때 성공해야 합니다.

Refactor (리팩터링)

  • 코드를 개선합니다. 테스트가 모두 통과하는 상태이므로, 안심하고 코드를 리팩터링할 수 있습니다.
  • 중복 제거, 가독성 향상, 설계 개선, 성능 최적화 등을 수행합니다.
  • 리팩터링 후에도 모든 테스트가 여전히 성공하는지 확인합니다. 실패한다면 리팩터링이 잘못된 것이므로 되돌리거나 수정해야 합니다.

이 세 단계를 작은 기능 단위로 계속 반복하면서 소프트웨어를 점진적으로 개발해나갑니다.


TDD의 주요 이점

TDD는 다음과 같은 여러 가지 중요한 이점을 제공합니다.

높은 코드 품질 및 신뢰성

  • 모든 코드가 테스트 커버리지를 가지므로, 버그 발생률이 낮아지고 코드의 안정성이 높아집니다.
  • 테스트가 없는 코드에 비해 리팩터링이나 기능 추가 시 변경에 대한 두려움을 줄여줍니다.

명확한 설계

  • 테스트를 먼저 작성하면서 코드의 외부 API(인터페이스)를 먼저 고민하게 됩니다. 이는 사용하기 쉬운 API와 응집도 높은 모듈을 설계하는 데 도움이 됩니다 (OCP, ISP, DIP와 연관).
  • 복잡한 기능을 한 번에 구현하기보다, 테스트를 통해 작은 단위로 쪼개어 구현하게 되므로 설계가 점진적으로 개선됩니다.

빠른 피드백

  • 기능 구현과 동시에 테스트를 통해 즉각적인 피드백을 받을 수 있습니다. 이는 문제점을 조기에 발견하고 해결하는 데 도움을 줍니다.

효율적인 리팩터링

  • 테스트 스위트가 일종의 안전망 역할을 하므로, 개발자는 마음 놓고 코드를 개선하고 최적화할 수 있습니다. 리팩터링으로 인해 기존 기능이 손상되지 않음을 테스트가 보장해줍니다.

문서화 역할

  • 잘 작성된 테스트 코드는 해당 기능의 작동 방식과 기대되는 동작에 대한 훌륭한 예시이자 문서 역할을 합니다.

TDD 실습 예제 (타입스크립트)

간단한 문자열 유틸리티 함수를 TDD 방식으로 개발하는 과정을 살펴보겠습니다. 여기서는 Jest 프레임워크를 사용합니다.

설정: 프로젝트를 초기화하고 Jest 및 타입스크립트 관련 의존성을 설치합니다.

# 프로젝트 초기화
mkdir tdd-example && cd tdd-example
npm init -y

# TypeScript 및 Jest 설치
npm install --save-dev typescript ts-jest @types/jest jest

tsconfig.json 파일을 설정합니다.

tsconfig.json
{
  "compilerOptions": {
    "target": "es2020",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist"
  }
}

jest.config.js 파일을 설정합니다.

jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
};

package.json에 테스트 스크립트를 추가합니다.

package.json
{
  "name": "tdd-example",
  "version": "1.0.0",
  "description": "",
  "main": "dist/index.js",
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watchAll"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/jest": "^29.5.12",
    "jest": "^29.7.0",
    "ts-jest": "^29.1.5",
    "typescript": "^5.5.3"
  }
}

TDD 사이클 시작: capitalize 함수 개발

Red (실패하는 테스트 작성)

src/utils/string.test.ts 파일 생성

src/utils/string.test.ts
import { capitalize } from './string'; // 아직 존재하지 않는 함수 임포트

describe('capitalize', () => {
  it('should capitalize the first letter of a string', () => {
    expect(capitalize('hello')).toBe('Hello');
  });

  it('should return an empty string for an empty input', () => {
    expect(capitalize('')).toBe('');
  });

  it('should handle strings with leading spaces', () => {
    expect(capitalize('  world')).toBe('  World');
  });

  it('should return the same string if the first character is not a letter', () => {
    expect(capitalize('123test')).toBe('123test');
    expect(capitalize('!abc')).toBe('!abc');
  });

  it('should handle single character strings', () => {
    expect(capitalize('a')).toBe('A');
  });
});

이제 npm test를 실행하면 src/utils/string.ts 파일이 없어서 오류가 나거나, 함수가 정의되지 않았다는 오류가 발생합니다. (Red 상태)

Green (테스트를 통과하는 최소한의 코드 작성)

src/utils/string.ts 파일 생성

src/utils/string.ts
export function capitalize(str: string): string {
  if (str.length === 0) {
    return '';
  }
  // 가장 간단하게 첫 글자만 대문자로 바꾸는 로직
  return str.charAt(0).toUpperCase() + str.slice(1);
}

npm test를 다시 실행합니다. 모든 테스트가 통과하는지 확인합니다. (Green 상태)

만약 should handle strings with leading spaces 테스트가 실패했다면, 그 테스트만 통과시키는 로직을 추가합니다. (예: str.trimStart().charAt(0).toUpperCase() + str.trimStart().slice(1) 등) 하지만 현재 capitalize 함수는 단순히 첫 글자만 바꾸므로, " world" -> " World"가 되어 해당 테스트는 통과할 것입니다.

Refactor (코드 개선)

현재 capitalize 함수는 상당히 단순하므로 리팩터링할 부분이 많지 않을 수 있습니다. 하지만 더 복잡한 함수였다면 이 단계에서 다음과 같은 작업을 할 수 있습니다.

  • 변수 이름 변경, 함수 분리
  • 중복 코드 제거
  • 가독성 향상
  • 성능 최적화 (단, 테스트 통과가 유지되는 선에서)

현재 코드의 가독성은 괜찮아 보이지만, 나중에 만약 '첫 글자가 영문자가 아닐 경우'에 대한 요구사항이 변경된다면, 리팩터링을 통해 해당 로직을 더 명확히 분리할 수 있습니다.

src/utils/string.ts (리팩터링 후)
export function capitalize(str: string): string {
  if (str.length === 0) {
    return '';
  }
  const firstChar = str.charAt(0);
  // 만약 첫 글자가 영문자인 경우만 대문자로 만들고 싶다면 조건 추가 가능
  // if (/[a-zA-Z]/.test(firstChar)) {
  //   return firstChar.toUpperCase() + str.slice(1);
  // }
  return firstChar.toUpperCase() + str.slice(1);
}

리팩터링 후에도 npm test를 다시 실행하여 모든 테스트가 여전히 통과하는지 확인합니다.

TDD 사이클 반복: reverseString 함수 개발

Red (실패하는 테스트 작성)

src/utils/string.test.ts에 새로운 테스트 블록 추가

src/utils/string.test.ts (reverseString 테스트 추가)
// ... capitalize tests ...

import { reverseString } from './string'; // 이제 reverseString도 임포트

describe('reverseString', () => {
  it('should reverse a given string', () => {
    expect(reverseString('hello')).toBe('olleh');
  });

  it('should return an empty string for an empty input', () => {
    expect(reverseString('')).toBe('');
  });

  it('should handle single character strings', () => {
    expect(reverseString('a')).toBe('a');
  });

  it('should handle strings with spaces', () => {
    expect(reverseString('hello world')).toBe('dlrow olleh');
  });
});

npm test 실행 시 reverseString이 정의되지 않았다는 오류 발생 (Red 상태).

Green (테스트를 통과하는 최소한의 코드 작성)

src/utils/string.tsreverseString 함수 추가:

src/utils/string.ts
export function capitalize(str: string): string {
  // ... 기존 capitalize 함수 ...
}

export function reverseString(str: string): string {
  // 가장 간단한 방법으로 뒤집기
  return str.split('').reverse().join('');
}

npm test 실행 시 모든 테스트 통과 (Green 상태).

Refactor (코드 개선)

reverseString 역시 현재는 간결하므로 크게 리팩터링할 부분은 없습니다. 만약 성능이 중요한 상황이라면, split().reverse().join() 방식 대신 루프를 사용하는 방식으로 변경할 수도 있습니다. 중요한 것은 변경 후에도 테스트가 모두 통과하는지 확인하는 것입니다.

이러한 Red-Green-Refactor 사이클을 반복하면서 점진적으로 기능을 추가하고 코드를 개선해 나가는 것이 TDD의 핵심입니다.


TDD의 현실적인 적용과 도전 과제

TDD는 강력한 방법론이지만, 모든 상황에 완벽하게 적용하기는 어려울 수 있습니다.

  • 초기 학습 곡선: TDD에 익숙하지 않은 개발자는 처음에는 개발 속도가 느려진다고 느낄 수 있습니다.
  • 레거시 코드: 테스트가 없는 레거시 코드에 TDD를 적용하기는 매우 어렵습니다. 이 경우, 기존 코드에 대한 "캐릭터라이제이션 테스트(Characterization Tests)"를 먼저 작성하여 기존 동작을 파악한 후, 리팩터링을 시작하는 것이 좋습니다.
  • UI/프레젠테이션 로직: UI 컴포넌트나 프레젠테이션 로직은 단위 테스트로만 TDD를 적용하기 어려울 수 있으며, 통합 테스트나 E2E 테스트가 함께 필요합니다.
  • 복잡한 외부 의존성: 데이터베이스, 네트워크 요청 등 외부 의존성을 가진 코드에 대한 테스트는 목(Mock) 객체나 스텁(Stub)을 적절히 사용하여 의존성을 격리해야 합니다.

그럼에도 불구하고, TDD는 소프트웨어 품질과 개발 생산성을 높이는 데 매우 효과적인 접근 방식이며, 특히 핵심 비즈니스 로직과 복잡한 알고리즘을 개발할 때 그 가치가 빛을 발합니다. 타입스크립트의 강력한 타입 시스템은 TDD와 결합될 때 더욱 견고한 소프트웨어 개발을 가능하게 합니다.


이것으로 10장 "객체지향 프로그래밍"을 마칩니다. 객체지향 원칙과 디자인 패턴, 의존성 주입, 그리고 TDD는 모두 견고하고 확장 가능한 소프트웨어를 만들기 위한 상호 보완적인 개념들입니다. 이들을 잘 이해하고 적용하는 것은 훌륭한 소프트웨어 엔지니어가 되기 위한 필수적인 역량입니다.