
TDD: 테스트가 없으면 코딩도 없다
코드를 먼저 짜고 테스트하는 게 아닙니다. 테스트를 먼저 짜고, 그걸 통과하기 위해 코딩하는 것. 순서를 뒤집으면 버그가 사라집니다.

코드를 먼저 짜고 테스트하는 게 아닙니다. 테스트를 먼저 짜고, 그걸 통과하기 위해 코딩하는 것. 순서를 뒤집으면 버그가 사라집니다.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

창업 초기, 나는 "빨리 만들어야 해"라는 강박에 테스트 없이 코드를 짰다. 유저 인증 기능을 3시간 만에 완성했다. 뿌듯했다. 그런데 다음 날 아침, Slack에 메시지가 50개 쌓여 있었다. "로그인이 안 돼요." 알고 보니 이메일에 대문자가 들어가면 로그인이 실패했다. user@Example.com과 user@example.com을 다른 계정으로 취급한 것이다.
수정하고 배포했다. 이번엔 소셜 로그인이 깨졌다. 이메일을 소문자로 바꾸는 코드가 OAuth 로직에도 영향을 미친 것이다. 하나를 고치면 다른 게 깨지는 두더지 잡기 게임이 시작됐다. 3시간 짠 코드를 고치는 데 이틀이 걸렸다.
그때 CTO 출신 멘토가 말했다. "지금 당신은 땅 다지지 않고 건물을 짓고 있어요. TDD를 배우세요."
TDD(Test Driven Development)는 코드 작성 순서를 완전히 뒤집는 방법론이다.
일반적인 개발:
calculator.ts 파일을 만든다.add(a, b) 함수를 짠다.console.log(add(2, 3))을 찍어서 5가 나오는지 확인한다.TDD:
add 함수도 없는데!).이게 바로 Red-Green-Refactor 사이클이다. 처음엔 "왜 코드를 두 번 짜야 해?"라고 생각했다. 하지만 회계사의 복식부기(Double-Entry Bookkeeping)를 떠올리면 이해가 쉽다. 회계사는 모든 거래를 차변과 대변에 두 번 기록한다. 한쪽만 기록하면 실수가 생기고, 양쪽이 일치하지 않으면 오류를 즉시 발견할 수 있다.
TDD도 마찬가지다. 의도(Test)와 구현(Code)을 따로 작성해서 서로 검증한다. 둘이 맞지 않으면 버그가 있다는 신호다.
실제로 TDD로 계산기의 add 함수를 만들어보자. Jest를 사용한다.
// calculator.test.ts
import { add } from './calculator';
describe('Calculator', () => {
it('should add two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
});
당연히 실패한다. calculator.ts 파일도 없고, add 함수도 없으니까. 에러 메시지가 나오는 게 정상이다.
Cannot find module './calculator'
// calculator.ts
export function add(a: number, b: number): number {
return 5; // 하드코딩!
}
이렇게 짜면 "비효율적인 거 아니야?"라고 생각할 수 있다. 맞다. 하지만 이게 TDD의 핵심이다. 지금 당장 통과해야 할 테스트만 생각한다. 테스트가 통과하면:
✓ should add two positive numbers (2 ms)
it('should add two negative numbers', () => {
expect(add(-1, -4)).toBe(-5);
});
이제 하드코딩으로는 통과할 수 없다. 진짜 구현을 해야 한다.
export function add(a: number, b: number): number {
return a + b;
}
모든 테스트가 통과한다.
지금은 코드가 간단해서 리팩토링할 게 없지만, 실제로는 이 단계에서 변수명을 바꾸거나 중복 코드를 제거한다. 테스트가 있으니까 리팩토링해도 안전하다. 테스트가 깨지면 바로 알 수 있으니까.
실제로는 외부 API를 호출하는 코드를 자주 짠다. 문제는 실제 API를 호출하면 테스트가 느리고, 비용이 들고, 불안정하다는 것이다. 네트워크가 끊기면? API 서버가 점검 중이면? 테스트가 깨진다.
그래서 우리는 Test Double을 사용한다. 영화에서 위험한 장면을 찍을 때 배우 대신 스턴트 더블이 연기하듯이, 실제 API 대신 가짜 객체를 사용하는 것이다.
Test Double에는 여러 종류가 있다:
구체적인 예제를 보자. 사용자 정보를 가져오는 함수를 테스트한다.
// userService.ts
export async function getUserName(userId: string): Promise<string> {
const response = await fetch(`https://api.example.com/users/${userId}`);
const data = await response.json();
return data.name;
}
테스트에서 실제 API를 호출하면 안 된다. Mock을 사용하자.
// userService.test.ts
import { getUserName } from './userService';
// fetch를 Mock으로 교체
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ name: 'Alice' }),
})
) as jest.Mock;
describe('getUserName', () => {
it('should fetch user name from API', async () => {
const name = await getUserName('123');
expect(name).toBe('Alice');
expect(fetch).toHaveBeenCalledWith('https://api.example.com/users/123');
expect(fetch).toHaveBeenCalledTimes(1); // Mock의 진가: 호출 횟수 검증
});
it('should handle different user IDs', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
json: () => Promise.resolve({ name: 'Bob' }),
});
const name = await getUserName('456');
expect(name).toBe('Bob');
});
});
이제 네트워크 없이도 테스트가 돌아간다. 0.5초 걸리던 테스트가 5ms로 줄어든다.
테스트는 크게 세 가지로 나뉜다:
전통적인 Testing Pyramid는 이렇게 말한다:
이유는? 빠른 테스트가 많을수록 개발 속도가 빨라지니까.
하지만 Kent C. Dodds는 Testing Trophy를 제안했다. 현대 웹 개발에서는 Integration Test를 가장 많이 작성하는 게 효율적이라는 것이다. Unit Test는 구현 세부사항에 너무 의존해서, 리팩토링할 때마다 테스트가 깨진다. Integration Test는 "유저가 로그인 버튼을 누르면 대시보드로 이동한다"처럼 실제 사용 시나리오를 테스트하니까 더 가치 있다.
나는 둘 다 쓴다. 복잡한 비즈니스 로직은 Unit Test로, 유저 플로우는 Integration Test로 검증한다.
TDD 커뮤니티는 두 진영으로 나뉜다.
Detroit School (Classicist):
London School (Mockist):
나는 디트로이트 스타일을 선호한다. Mock을 너무 많이 쓰면 "Mock을 위한 Mock"을 만들게 되고, 진짜 통합 버그를 놓치기 쉽다. 하지만 API 호출이나 결제 로직처럼 비용이 드는 부분은 당연히 Mock을 쓴다.
TDD를 하다 보면 테스트 이름이 이상해진다.
it('test_add_function', () => { ... });
이게 뭘 테스트하는 건지 나중에 보면 모른다. BDD (Behavior Driven Development)는 이 문제를 해결한다. 테스트를 자연어처럼 작성하는 것이다.
describe('Calculator', () => {
describe('when adding two numbers', () => {
it('should return the sum', () => {
expect(add(2, 3)).toBe(5);
});
it('should handle negative numbers', () => {
expect(add(-2, -3)).toBe(-5);
});
});
});
Gherkin 문법을 쓰면 더 명확하다:
Feature: User Login
Scenario: User logs in with valid credentials
Given the user is on the login page
When they enter valid email and password
Then they should see the dashboard
이렇게 쓰면 비개발자(PM, 디자이너)도 테스트를 읽을 수 있다. 실제로 Cucumber 같은 도구는 이 문법을 코드로 바꿔준다.
많은 회사가 "코드 커버리지 80% 이상"을 목표로 삼는다. 커버리지는 테스트가 실행한 코드의 비율이다. 100% 커버리지면 모든 줄이 테스트됐다는 뜻이다.
하지만 이건 숫자 놀음이 될 수 있다.
function divide(a: number, b: number): number {
return a / b; // 커버리지 100%
}
it('should divide numbers', () => {
expect(divide(10, 2)).toBe(5);
});
이 테스트는 divide(10, 0)을 체크하지 않는다. 0으로 나누면 Infinity가 나온다. 커버리지는 100%인데 버그가 있다.
나는 커버리지를 방향 지표로만 본다. 30% 이하면 "테스트가 너무 없구나", 90% 이상이면 "충분하구나" 정도. 100%를 목표로 삼으면 의미 없는 테스트만 늘어난다.
TDD가 빛나는 순간:
TDD가 오버헤드인 순간:
나는 제품 초기엔 TDD를 안 쓴다. 빠르게 만들어서 유저 반응을 봐야 하니까. 하지만 제품이 안정되고 팀이 커지면, TDD 없이는 개발이 불가능하다. 10명이 동시에 코드를 수정하는데 테스트가 없으면 매일 버그가 터진다.
React 컴포넌트를 테스트할 때, "버튼이 있는지", "텍스트가 맞는지" 일일이 체크하면 귀찮다. Snapshot Testing은 이걸 자동화한다.
import { render } from '@testing-library/react';
import { Button } from './Button';
it('should match snapshot', () => {
const { container } = render(<Button>Click me</Button>);
expect(container).toMatchSnapshot();
});
처음 실행하면 Jest가 HTML 스냅샷을 저장한다. 다음 실행부터는 현재 출력과 스냅샷을 비교한다. 달라지면 테스트가 실패한다.
장점: UI 변경을 빠르게 감지한다. 단점: 의도하지 않은 변경(띄어쓰기 하나)에도 테스트가 깨진다. 스냅샷을 너무 맹신하면 안 된다.
나는 스냅샷 테스트를 회귀 방지용으로만 쓴다. 중요한 UI 로직은 따로 테스트한다.
처음 TDD를 시작할 때 어떤 도구를 쓸지 고민했다.
Jest: React 생태계의 표준. 설정 없이 바로 쓸 수 있다. Mocking 기능이 강력하다. Vitest: Vite 기반. Jest보다 10배 빠르다. ES Module 네이티브 지원. Mocha: 오래된 도구. 유연하지만 설정이 복잡하다. pytest (Python): Python 진영의 사실상 표준. Fixture 시스템이 훌륭하다.
나는 TypeScript 프로젝트에선 Vitest를 쓴다. 속도가 압도적이다. Python에선 당연히 pytest.
처음 TDD를 배울 때, "이거 시간 낭비 아니야?"라고 생각했다. 테스트 짜는 시간에 기능 하나 더 만들 수 있을 것 같았다. 하지만 6개월 후, 테스트가 없던 코드는 전부 레거시가 됐다. 누구도 건드리지 못하는 지뢰밭이 됐다.
TDD는 느린 게 아니라 빠른 것이다. 당장은 느려 보여도, 나중에 버그 잡느라 며칠을 날리지 않는다. 밤 11시에 배포했는데 서버가 터져서 새벽 3시까지 롤백하는 일도 없다.
지금 내가 배운 교훈: 테스트 없이 배포하지 말라. 테스트는 보험이 아니라 개발 속도를 높이는 투자다. Red-Green-Refactor 사이클을 돌리다 보면, 어느새 버그가 사라진 코드를 짜고 있을 것이다.