Red — 실패하는 테스트가 설계를 이끈다

Red-Green-Refactor 사이클의 첫 단계, Red는 가장 많이 오해받는 단계다. 단순히 “테스트를 먼저 쓴다”가 아니다. 아직 존재하지 않는 코드를 사용하는 테스트를 작성하면서 사용하는 사람 관점에서 설계를 탐색하는 단계다.

왜 테스트를 먼저 쓰는가

구현을 먼저 하면 만든 사람의 관점에서 설계가 결정된다. 테스트를 먼저 쓰면 사용하는 사람의 관점에서 설계가 시작된다.

// 구현 먼저: 만드는 사람 관점
class DiscountCalculator {
  // 내가 편한 방식으로 설계
  computeWithAppliedDiscount(
    rawAmount: number,
    discountConfig: { type: string; value: number; conditions: object }
  ): { amount: number; appliedRules: string[] } { ... }
}
 
// 테스트 먼저: 사용하는 사람 관점
// 테스트를 쓰면서 "어떻게 호출하고 싶은가"를 먼저 결정한다
it('10만원 이상 구매 시 10% 할인을 적용한다', () => {
  const calculator = new CartDiscountCalculator();
 
  const result = calculator.calculate(110000);
 
  expect(result).toBe(99000);
});

두 번째 방식에서 calculate는 인자가 단순하고 반환값도 직접적이다. 사용하는 입장에서 필요한 것만 드러난다.

컴파일 오류도 실패다

TypeScript에서 TDD를 할 때 중요한 원칙이 있다. 컴파일 오류도 Red 상태다. 존재하지 않는 클래스를 테스트에서 참조하면 컴파일 오류가 난다. 이것이 첫 번째 Red다.

컴파일이 통과할 때까지 최소한의 인터페이스를 정의하는 것이 Green으로 가는 첫 걸음이다.

// 1단계: 테스트 작성 — 컴파일 오류 (Red)
import { CartDiscountCalculator } from './CartDiscountCalculator';
//                ^--- 파일이 없음: 컴파일 오류
 
describe('CartDiscountCalculator', () => {
  it('10만원 이상 구매 시 10% 할인을 적용한다', () => {
    const calculator = new CartDiscountCalculator(); // 클래스 없음
    const result = calculator.calculate(110000);     // 메서드 없음
    expect(result).toBe(99000);
  });
});
 
// 2단계: 컴파일만 통과하는 최소 인터페이스 정의
// CartDiscountCalculator.ts
export class CartDiscountCalculator {
  calculate(amount: number): number {
    return 0; // 구현 없음, 컴파일만 통과
  }
}
// 이제 컴파일은 통과하지만 테스트는 실패 — Red 유지

인터페이스를 정의하는 단계에서 이미 설계 결정이 일어난다. 클래스 이름, 메서드 이름, 파라미터 타입, 반환 타입.

테스트 이름 작성법

테스트 이름은 코드의 명세다. 나쁜 테스트 이름은 테스트를 무의미하게 만든다.

// 나쁜 테스트 이름 — 무엇을 검증하는지 불명확
it('할인 계산기 테스트', () => { ... });
it('calculate works', () => { ... });
it('test1', () => { ... });
 
// 좋은 테스트 이름 — 조건과 기대 결과가 명확
it('10만원 이상 구매 시 10% 할인을 적용한다', () => { ... });
it('10만원 미만 구매 시 할인을 적용하지 않는다', () => { ... });
it('할인 적용 후 금액이 0원 이하면 0원을 반환한다', () => { ... });

패턴 1: [조건] + [기대 결과]

it('VIP 회원이 구매하면 15% 할인을 적용한다', () => { ... });
it('재고가 0개이면 구매할 수 없다', () => { ... });

패턴 2: should_[기대 결과]_when_[조건]

it('should_apply_10_percent_discount_when_amount_is_over_100000', () => { ... });

패턴 3: describe 계층으로 조건 분리

describe('CartDiscountCalculator', () => {
  describe('10만원 이상 구매 시', () => {
    it('10% 할인을 적용한다', () => { ... });
    it('할인 후 금액을 반환한다', () => { ... });
  });
 
  describe('10만원 미만 구매 시', () => {
    it('할인을 적용하지 않는다', () => { ... });
    it('원래 금액을 그대로 반환한다', () => { ... });
  });
});

하나의 테스트, 하나의 이유

하나의 테스트는 하나의 이유로만 실패해야 한다. 여러 동작을 한 테스트에서 검증하면 어디가 문제인지 알기 어렵다.

// 나쁜 예: 여러 이유로 실패할 수 있다
it('장바구니 할인 계산', () => {
  const calculator = new CartDiscountCalculator();
 
  expect(calculator.calculate(110000)).toBe(99000);  // 조건 1
  expect(calculator.calculate(50000)).toBe(50000);   // 조건 2
  expect(calculator.calculate(200000)).toBe(180000); // 조건 3
  expect(calculator.calculate(0)).toBe(0);           // 조건 4
});
 
// 좋은 예: 각 테스트는 하나의 동작을 검증한다
it('10만원 이상 구매 시 10% 할인을 적용한다', () => {
  expect(calculator.calculate(110000)).toBe(99000);
});
 
it('10만원 미만 구매 시 할인을 적용하지 않는다', () => {
  expect(calculator.calculate(50000)).toBe(50000);
});
 
it('정확히 10만원 구매 시 10% 할인을 적용한다', () => {
  expect(calculator.calculate(100000)).toBe(90000);
});

테스트가 정말 실패하는지 확인하라

Red 단계에서 놓치기 쉬운 것이 있다. 테스트가 실제로 실패하는지 확인해야 한다. 테스트를 작성했는데 이미 통과한다면 두 가지 중 하나다: 이미 구현되어 있거나, 테스트가 아무것도 검증하지 않는다.

// false positive의 예
it('할인을 적용한다', () => {
  const calculator = new CartDiscountCalculator();
  const result = calculator.calculate(110000);
  // expect가 없다! 항상 통과한다
});
 
// 또 다른 false positive
it('할인을 적용한다', () => {
  const calculator = new CartDiscountCalculator();
  const result = calculator.calculate(110000);
  expect(result).toBeDefined(); // 항상 참이라 의미 없다
});

테스트를 작성한 뒤 실행해서 빨간 줄을 직접 눈으로 확인하는 습관이 중요하다. 빨간 줄을 보지 않으면 Green으로 만들었을 때 무언가를 고쳤다는 확신이 없다.

실제 예시: 장바구니 할인 계산 TDD로 시작하기

요구사항: 장바구니 합계 금액이 10만원 이상이면 10% 할인, 그렇지 않으면 할인 없음.

Step 1: 첫 번째 테스트 작성 (파일조차 없음)

// cart-discount-calculator.test.ts
import { CartDiscountCalculator } from './cart-discount-calculator';
// ^^^ 아직 이 파일 없음 → 컴파일 오류 = Red
 
describe('CartDiscountCalculator', () => {
  it('10만원 이상 구매 시 10% 할인을 적용한다', () => {
    const calculator = new CartDiscountCalculator();
 
    const result = calculator.calculate(110000);
 
    expect(result).toBe(99000);
  });
});

Step 2: 컴파일 오류 수정 — 인터페이스 정의

// cart-discount-calculator.ts
export class CartDiscountCalculator {
  calculate(amount: number): number {
    throw new Error('Not implemented');
  }
}

이제 컴파일은 통과하고, 테스트는 Error: Not implemented로 실패한다. Red 상태 유지. 실패 메시지를 직접 확인한다.

Step 3: 두 번째 테스트 추가 (아직 구현 전)

describe('CartDiscountCalculator', () => {
  it('10만원 이상 구매 시 10% 할인을 적용한다', () => {
    const calculator = new CartDiscountCalculator();
    const result = calculator.calculate(110000);
    expect(result).toBe(99000);
  });
 
  it('10만원 미만 구매 시 할인을 적용하지 않는다', () => {
    const calculator = new CartDiscountCalculator();
    const result = calculator.calculate(50000);
    expect(result).toBe(50000);
  });
});

두 테스트 모두 실패한다. 이제 Green 단계로 넘어간다.

Red 단계의 목표는 테스트를 “제대로 실패시키는” 것이다. 실패하지 않는 테스트는 가치가 없다. 잘 실패하는 테스트를 만든 다음에야 Green으로 이동할 수 있다.