테스트 코드는 제품 코드의 보조물이 아니라, 제품 코드가 계속 바뀔 수 있게 붙잡아 주는 안전망이다. 테스트가 있으면 변경의 두려움이 줄어들고, 구조를 개선할 여유가 생긴다. 반대로 테스트가 지저분하면 제품 코드도 쉽게 지저분해진다. 읽기 어려운 테스트는 실패했을 때 원인을 알려주지 못하고, 결국 아무도 믿지 않는 코드가 된다. 이 장을 관통하는 관점은 하나다. 테스트 코드는 제품 코드만큼 중요하다. 성능 최적화의 대상은 아니지만 가독성만큼은 제품 코드보다 더 높아야 한다.

TDD의 세 가지 법칙

TDD는 아주 짧은 주기로 움직인다. 세 가지 법칙이 그 주기를 정의한다.

  • 첫째, 실패하는 테스트를 작성하기 전에는 제품 코드를 작성하지 않는다.
  • 둘째, 컴파일은 되지만 실행이 실패하는 정도로만 테스트를 작성한다.
  • 셋째, 현재 실패하는 테스트를 통과할 만큼만 제품 코드를 작성한다.

이 세 법칙을 지키면 테스트와 제품 코드가 거의 동시에, 몇 초 간격으로 함께 자란다. 하루에도 수십, 수백 개의 테스트가 쌓인다.

// 1. 먼저 실패하는 테스트 (제품 코드 없음 → 컴파일조차 안 됨)
@Test
public void stackIsEmptyOnCreation() {
    Stack stack = new Stack();
    assertTrue(stack.isEmpty());
}
 
// 2. 통과할 만큼만 제품 코드
public class Stack {
    public boolean isEmpty() { return true; } // 지금은 이걸로 충분하다
}

판단 기준: “지금 실패하는 테스트를 통과시키는 데 필요한가”가 제품 코드 한 줄 한 줄의 유일한 정당화다. 함정: 이 규칙을 엄격히 따르면 테스트가 빠르게 늘어나므로, 테스트의 품질이 곧 프로젝트의 발목을 잡는다. 대충 작성한 테스트는 처음엔 속도가 나는 듯 보여도 시간이 지날수록 유지보수 비용이 제품 코드만큼, 때로는 그 이상으로 커진다.

깨끗한 테스트 코드: BUILD-OPERATE-CHECK

깨끗한 테스트에서 가장 중요한 것은 가독성이다. 테스트는 “무엇을 준비했고, 무엇을 실행했으며, 어떤 결과를 기대하는지”가 한눈에 보여야 한다. 이 흐름을 BUILD-OPERATE-CHECK(준비-실행-확인), 다른 이름으로 given-when-then 패턴이라 부른다.

public void testGetPageHierarchyAsXml() throws Exception {
    // BUILD  — 테스트 데이터를 준비
    givenPages("PageOne", "PageOne.ChildOne", "PageTwo");
 
    // OPERATE — 동작을 실행
    whenRequestIsIssued("root", "type:pages");
 
    // CHECK  — 결과를 확인
    thenResponseShouldBeXML();
    thenResponseShouldContain(
        "<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>");
}

세 구획이 시각적으로 분리되면 독자는 각 테스트가 어떤 시나리오를 검증하는지 즉시 파악한다. 판단 기준: 테스트 하나가 세 구획으로 깔끔하게 나뉘지 않는다면, 검증하려는 개념이 하나가 아니라는 신호일 때가 많다.

도메인 특화 테스트 언어

테스트 안에서 세부 API 호출이 너무 많이 드러나면 의도가 묻힌다. 위 예제의 givenPages, whenRequestIsIssued, thenResponseShouldContain은 모두 이 테스트를 위해 만든 도우미 함수다. 이렇게 테스트를 위한 유틸리티를 쌓아 가면, 테스트를 저수준 API가 아니라 도메인에 맞는 언어로 표현할 수 있다.

// before — 저수준 API 가 테스트 의도를 가린다
@Test
public void turnOnLoTempAlarmAtThreshold() throws Exception {
    hw.setTemp(WAY_TOO_COLD);
    controller.tic();
    assertTrue(hw.heaterState());
    assertTrue(hw.blowerState());
    assertFalse(hw.coolerState());
    assertFalse(hw.hiTempAlarm());
    assertTrue(hw.loTempAlarm());
}
 
// after — 도메인 언어로 상태를 한 줄에 표현
@Test
public void turnOnLoTempAlarmAtThreshold() throws Exception {
    wayTooCold();
    assertEquals("HBchL", hw.getState());
}

getState가 반환하는 "HBchL"은 규칙이 있다. 대문자는 켜짐, 소문자는 꺼짐이고 순서는 heater, blower, cooler, hi-temp, lo-temp다. 이 표기법을 한 번 익히면 기대 상태를 한눈에 읽는다. 판단 기준: 테스트 언어는 처음부터 설계하는 게 아니라, 테스트를 리팩터링하는 과정에서 점진적으로 자라난다. 함정: 축약이 지나치면 오히려 이해하기 어려워진다. "HBchL" 같은 표기가 팀에 낯설면 그 자체가 해독 대상이 된다. 중요한 것은 테스트를 읽는 사람이 기대 상태를 빠르게 파악할 수 있느냐다.

하나의 테스트에는 하나의 개념

“테스트당 assert 하나”라는 말은 결과 확인을 무조건 하나만 쓰라는 뜻이라기보다, 한 테스트가 하나의 개념만 검증해야 한다는 의미에 가깝다. 여러 assert가 같은 개념을 설명한다면 괜찮다. 하지만 서로 다른 조건을 한 테스트에 몰아넣으면 실패했을 때 무엇이 깨졌는지 파악하기 어렵다.

// before — 한 달 더하기와 두 달 더하기, 서로 다른 개념이 한 테스트에
public void testAddMonths() {
    SerialDate d1 = SerialDate.createInstance(31, 5, 2004);
    SerialDate d2 = SerialDate.addMonths(1, d1);
    assertEquals(30, d2.getDayOfMonth());   // 6월은 30일까지
    assertEquals(6, d2.getMonth());
    SerialDate d3 = SerialDate.addMonths(2, d1);
    assertEquals(31, d3.getDayOfMonth());   // 7월은 31일까지
    assertEquals(7, d3.getMonth());
}
 
// after — 개념마다 테스트를 나눈다
@Test public void addOneMonthToMayEnd_clampsToJune30() {
    SerialDate d = SerialDate.addMonths(1, SerialDate.createInstance(31, 5, 2004));
    assertEquals(30, d.getDayOfMonth());
    assertEquals(6, d.getMonth());
}
@Test public void addTwoMonthsToMayEnd_staysJuly31() {
    SerialDate d = SerialDate.addMonths(2, SerialDate.createInstance(31, 5, 2004));
    assertEquals(31, d.getDayOfMonth());
    assertEquals(7, d.getMonth());
}

after의 세 assert(일, 월)는 “6월 말로 잘린다”는 하나의 개념을 함께 설명하므로 한 테스트에 있어도 좋다. 판단 기준: assert 개수가 아니라 개념 개수가 기준이다. 테스트 이름을 지을 때 “그리고”가 필요하면 나눌 시점이다. 함정: 케이스를 나누면 테스트 수가 늘지만, 실패했을 때 이름만 보고 원인을 알 수 있다는 이득이 그 비용을 넘는다.

FIRST 원칙

좋은 테스트가 갖춰야 할 다섯 가지 성질을 F.I.R.S.T라는 약자로 정리한다.

  • Fast(빠르게) — 느린 테스트는 자주 실행하지 않게 되고, 자주 돌리지 않으면 문제를 늦게 발견한다.
  • Independent(독립적으로) — 테스트끼리 의존하면 하나의 실패가 줄줄이 다른 실패를 유발해 원인 파악을 어렵게 한다. 어떤 순서로 실행해도 결과가 같아야 한다.
  • Repeatable(반복 가능하게) — 환경(네트워크, 특정 장비, 시간)에 따라 결과가 달라지면 신뢰할 수 없다.
  • Self-Validating(자가 검증) — 테스트는 스스로 성공/실패를 boolean으로 판정해야 한다. 로그를 사람이 읽고 판단해야 한다면 자동화된 테스트가 아니다.
  • Timely(적시에) — 제품 코드를 다 만든 뒤 억지로 붙이는 테스트보다, 설계 과정에서 함께 작성한 테스트가 코드 구조를 더 잘 이끈다.
flowchart LR
    F["Fast<br/>빠르게"] --> I["Independent<br/>독립적으로"]
    I --> R["Repeatable<br/>반복 가능하게"]
    R --> S["Self-Validating<br/>자가 검증"]
    S --> T["Timely<br/>적시에"]

판단 기준: 어느 하나라도 무너지면 테스트에 대한 신뢰가 깨지고, 신뢰 없는 테스트는 실행되지 않으며, 실행되지 않는 테스트는 없는 것과 같다. 함정: 특히 Independent를 놓치기 쉽다. 앞 테스트가 남긴 전역 상태에 뒤 테스트가 기대는 순간, 테스트는 실행 순서라는 숨은 의존성을 갖게 된다.

마무리

깨끗한 테스트는 프로젝트의 유연성을 지켜 준다. 테스트가 읽기 쉽고 빠르며 믿을 수 있으면, 코드를 고치는 일이 덜 두려워진다.

  • TDD 세 법칙: 실패 테스트 없이 제품 코드 없다. 테스트와 제품 코드는 몇 초 간격으로 함께 자란다.
  • BUILD-OPERATE-CHECK로 준비/실행/확인을 시각적으로 나눈다. 도메인 특화 테스트 언어를 점진적으로 쌓아 의도를 드러낸다.
  • 하나의 테스트에는 하나의 개념. assert 개수가 아니라 개념 개수가 기준이다.
  • FIRST — 빠르고, 독립적이고, 반복 가능하고, 자가 검증하고, 적시에. 하나라도 무너지면 신뢰가 깨진다.

테스트 코드도 계속 다듬어야 하는 코드다. 제품 코드만큼은 아니더라도, 제품 코드를 지탱할 만큼의 품질은 유지해야 한다.

다음장으로 10장