테스트 주도 개발 (TDD)
실패 테스트·최소 구현·리팩터링의 Red-Green-Refactor 순환으로 요구사항을 작은 단위의 검증 가능한 코드로 발전시킵니다.
테스트 주도 개발(Test-Driven Development, TDD)은 코드보다 테스트를 먼저 작성하는 개발 방식입니다.
실패하는 테스트를 만들고, 그 테스트를 통과할 최소한의 코드를 작성한 뒤, 리팩토링하는 사이클을 반복합니다.
즉, 테스트를 단순 검증 도구가 아니라 설계 프로세스의 핵심으로 활용하는 접근입니다.
TDD는 Kent Beck이 XP(Extreme Programming)의 일환으로 주창했으며, 코드의 품질을 높이고 개발 생산성을 향상시키는 데 큰 기여를 합니다.
타입스크립트와 같은 강력한 타입 시스템을 가진 언어에서는 TDD를 통해 런타임 오류뿐만 아니라 타입 관련 오류까지도 더욱 견고하게 방지할 수 있습니다.
RED · GREEN · REFACTOR
작은 예제가 실행 가능한 명세를 성장시킨다
구체 예제를 고르고 의미 있는 실패를 확인한 뒤 최소 구현과 리팩터링을 거쳐 회귀 검사를 통과하면 다음 예제로 돌아간다.
- 관찰 가능한 작은
관찰 가능한 작은 예제를 고른다.
- 올바른 이유로 테스트가
올바른 이유로 테스트가 실패하는지 확인한다.
- 통과시키는 최소 구현을
통과시키는 최소 구현을 작성한다.
- 테스트가 녹색인 상태에서
테스트가 녹색인 상태에서 구조를 정리한다.
- 전체 회귀 검사를
전체 회귀 검사를 통과하고 다음 예제로 간다.
실패를 보지 않은 테스트와 동작을 바꾸는 리팩터링은 이 루프의 안전 장치가 아니다.
TDD의 세 가지 황금률
TDD는 다음의 세 가지 단계를 반복하는 사이클로 이루어집니다.
- 실패하는 테스트를 먼저 작성합니다. 현재 구현되어 있지 않거나, 의도적으로 잘못된 동작을 테스트하는 코드를 작성합니다.
- 이 테스트는 실행했을 때 반드시 실패해야 합니다. 실패하지 않는다면, 테스트 자체가 잘못되었거나, 이미 기능이 구현되어 있는 경우입니다.
- 이 단계에서 작성하는 테스트는 개발하려는 기능의 명세를 정의하는 역할을 합니다.
- 테스트를 통과시킬 수 있는 최소한의 코드를 작성합니다.
- 이 단계에서는 오직 테스트를 통과시키는 것에만 집중하고, 코드의 아름다움이나 일반화는 고려하지 않습니다. 가장 빠르고 간단한 방법으로 테스트를 통과시킵니다.
- 테스트가 실행했을 때 성공해야 합니다.
- 코드를 개선합니다. 테스트가 모두 통과하는 상태이므로, 안심하고 코드를 리팩터링할 수 있습니다.
- 중복 제거, 가독성 향상, 설계 개선, 성능 최적화 등을 수행합니다.
- 리팩터링 후에도 모든 테스트가 여전히 성공하는지 확인합니다. 실패한다면 리팩터링이 잘못된 것이므로 되돌리거나 수정해야 합니다.
이 세 단계를 작은 기능 단위로 계속 반복하면서 소프트웨어를 점진적으로 개발해나갑니다.
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 jesttsconfig.json 파일을 설정합니다.
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
}
}jest.config.js 파일을 설정합니다.
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
};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"
}
}capitalize 함수 개발
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 상태)
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를 다시 실행합니다.
원문 구현은 첫 문자인 공백만 대문자화하므로 capitalize(' world')는 그대로 ' world'입니다. 따라서 앞 공백을 유지하며 W를 대문자화하라는 테스트에는 실패하고, 아직 Green이 아닙니다. trimStart()로 결과를 만들면 앞 공백이 사라지므로 그것만으로도 기대값을 충족하지 않습니다. 앞 공백을 보존한 채 첫 비공백 문자를 처리하는 구현이 필요합니다.
앞 공백 테스트까지 통과시킨 뒤 리팩터링해야 합니다. 아래 원문은 첫 문자 처리의 구조만 분리한 예이며, 남아 있는 공백 요구를 해결하지 않습니다.
하지만 더 복잡한 함수였다면 이 단계에서 다음과 같은 작업을 할 수 있습니다.
- 변수 이름 변경, 함수 분리
- 중복 코드 제거
- 가독성 향상
- 성능 최적화 (단, 테스트 통과가 유지되는 선에서)
현재 코드의 가독성은 괜찮아 보이지만, 나중에 만약 첫 글자가 영문자가 아닐 경우에 대한 요구사항이 변경된다면, 리팩터링을 통해 해당 로직을 더 명확히 분리할 수 있습니다.
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를 다시 실행하여 모든 테스트가 여전히 통과하는지 확인합니다.
reverseString 함수 개발
src/utils/string.test.ts에 새로운 테스트 블록 추가
// ... 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 상태).
src/utils/string.ts에 reverseString 함수 추가:
export function capitalize(str: string): string {
// ... 기존 capitalize 함수 ...
}
export function reverseString(str: string): string {
// 가장 간단한 방법으로 뒤집기
return str.split('').reverse().join('');
}제시한 ASCII 범위의 reverseString 기대값은 이 구현과 일치합니다. 다만 전체 스위트가 Green이 되려면 앞서 남은 capitalize 실패도 먼저 해결해야 합니다. 실제 실행 결과를 제시한 것은 아닙니다.
reverseString 역시 현재는 간결하므로 크게 리팩터링할 부분은 없습니다.
만약 성능이 중요한 상황이라면, split().reverse().join() 방식 대신 루프를 사용하는 방식으로 변경할 수도 있습니다.
중요한 것은 변경 후에도 테스트가 모두 통과하는지 확인하는 것입니다.
이러한 Red-Green-Refactor 사이클을 반복하면서 점진적으로 기능을 추가하고 코드를 개선해 나가는 것이 TDD의 핵심입니다.
TDD의 현실적인 적용과 도전 과제
TDD는 유용한 방법론이지만, 모든 상황에 완벽하게 적용하기는 어려울 수 있습니다.
보호할 위험에 따라 단위·통합·E2E 중 필요한 범위를 선택합니다.
- 초기 학습 곡선: TDD에 익숙하지 않은 개발자는 처음에는 개발 속도가 느려진다고 느낄 수 있습니다.
- 레거시 코드: 테스트가 없는 레거시 코드에 TDD를 적용하기는 매우 어렵습니다. 이 경우, 기존 코드에 대한 캐릭터라이제이션 테스트(Characterization Tests)를 먼저 작성하여 기존 동작을 파악한 후, 리팩터링을 시작하는 것이 좋습니다.
- UI/프레젠테이션 로직: UI 컴포넌트나 프레젠테이션 로직은 단위 테스트로만 TDD를 적용하기 어려울 수 있으며, 통합 테스트나 E2E 테스트가 함께 필요합니다.
- 복잡한 외부 의존성: 데이터베이스, 네트워크 요청 등 외부 의존성을 가진 코드에 대한 테스트는 목(Mock) 객체나 스텁(Stub)을 적절히 사용하여 의존성을 격리해야 합니다.
그럼에도 불구하고, TDD는 소프트웨어 품질과 개발 생산성을 높이는 데 매우 효과적인 접근 방식이며, 특히 핵심 비즈니스 로직과 복잡한 알고리즘을 개발할 때 그 가치가 빛을 발합니다.
타입스크립트의 강력한 타입 시스템은 TDD와 결합될 때 더욱 견고한 소프트웨어 개발을 가능하게 합니다.
이것으로 10장 객체지향 프로그래밍을 마칩니다.
객체지향 원칙, 디자인 패턴, 의존성 주입, TDD는 경계 설정, 의존성 방향, 테스트 단위를 정할 때 함께 검토하는 개념입니다.
TDD는 적용 범위를 잘 고르는 것이 중요하므로, 아래 기준으로 우선순위를 잡으면 현실적인 반복이 가능합니다.
TEST SCOPE · EVIDENCE
관찰하려는 위험에 맞춰 테스트 범위를 고른다
순수 규칙·기존 동작·외부 경계·사용자 여정 중 무엇을 보호할지 정하고 가장 작은 증거 범위를 선택한다.
- 순수 규칙은 unit
순수 규칙은 unit test로 검증한다.
- 레거시 동작 고정은
레거시 동작 고정은 characterization test를 쓴다.
- DB·HTTP 경계는 integration
DB·HTTP 경계는 integration test로 검증한다.
- 사용자 여정은 꼭
사용자 여정은 꼭 필요한 E2E로 검증한다.
- type-check는 런타임 테스트를
type-check는 런타임 테스트를 대신하지 않는다.
테스트 피라미드는 개수 할당표가 아니라 피드백 속도와 현실성의 균형이다.