테스트 코드는 제품 코드의 보조물이 아니라, 제품 코드가 계속 바뀔 수 있게 붙잡아 주는 안전망이다. 테스트가 있으면 변경의 두려움이 줄어들고, 구조를 개선할 여유가 생긴다. 반대로 테스트가 지저분하면 제품 코드도 쉽게 지저분해진다. 읽기 어려운 테스트는 실패했을 때 원인을 알려주지 못하고, 결국 아무도 믿지 않는 코드가 된다.

TDD의 세 가지 법칙

TDD는 아주 짧은 주기로 움직인다. 실패하는 테스트를 먼저 작성하고, 그 테스트가 컴파일은 되지만 실행은 실패하는 정도에서 멈춘다. 그다음 현재 실패한 테스트를 통과할 만큼만 제품 코드를 작성한다.

이 규칙을 엄격히 따르면 테스트 코드는 빠르게 늘어난다. 그래서 더더욱 테스트의 품질이 중요하다. 테스트를 대충 작성하면 처음에는 속도가 나는 것처럼 보여도, 시간이 지날수록 유지보수 비용이 제품 코드만큼 커진다.

깨끗한 테스트 코드

깨끗한 테스트에서 가장 중요한 것은 가독성이다. 테스트는 “무엇을 준비했고, 무엇을 실행했으며, 어떤 결과를 기대하는지”가 한눈에 보여야 한다. 보통 이 흐름은 Build-Operate-Check, 또는 given-when-then 구조로 정리할 수 있다.

public void testGetPageHierarchyAsXml() throws Exception {
    givenPages("PageOne", "PageOne.ChildOne", "PageTwo");
 
    whenRequestIsIssued("root", "type:pages");
 
    thenResponseShouldBeXML();
    thenResponseShouldContain(
        "<name>PageOne</name>",
        "<name>PageTwo</name>",
        "<name>ChildOne</name>"
    );
}

테스트 안에서 세부 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());
}

테스트의 맥락에서 상태를 간결하게 표현할 수 있다면 아래처럼 읽기 쉬운 방식도 가능하다.

@Test
public void turnOnLoTempAlarmAtThreshold() throws Exception {
    wayTooCold();
    assertEquals("HBchL", hw.getState());
}

물론 축약이 지나치면 오히려 이해하기 어려워진다. 중요한 것은 테스트를 읽는 사람이 기대 상태를 빠르게 파악할 수 있느냐다.

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

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

public void testAddMonths() {
    SerialDate d1 = SerialDate.createInstance(31, 5, 2004);
 
    SerialDate d2 = SerialDate.addMonths(1, d1);
    assertEquals(30, d2.getDayOfMonth());
    assertEquals(6, d2.getMonth());
    assertEquals(2004, d2.getYYYY());
 
    SerialDate d3 = SerialDate.addMonths(2, d1);
    assertEquals(31, d3.getDayOfMonth());
    assertEquals(7, d3.getMonth());
    assertEquals(2004, d3.getYYYY());
}

이 테스트는 날짜 계산이라는 큰 주제 아래 여러 경우를 함께 담고 있다. 독자가 한 번에 이해하기 어렵다면 케이스를 나누는 편이 좋다. 테스트가 조금 늘어나더라도 개념이 분리되면 실패 원인이 더 선명해진다.

FIRST 원칙

좋은 테스트는 빠르고 독립적이며 반복 가능해야 한다. 느린 테스트는 자주 실행하지 않게 되고, 서로 의존하는 테스트는 하나의 실패가 여러 실패처럼 보이게 만든다. 환경에 따라 결과가 달라지는 테스트도 신뢰하기 어렵다.

또한 테스트는 스스로 성공과 실패를 판단해야 한다. 로그를 사람이 읽고 판정해야 한다면 자동화된 테스트라고 보기 어렵다. 마지막으로 테스트는 적절한 시점에 작성되어야 한다. 제품 코드를 다 만든 뒤에 억지로 붙이는 테스트보다, 설계 과정에서 함께 작성한 테스트가 코드의 구조를 더 잘 이끈다.

마무리

깨끗한 테스트는 프로젝트의 유연성을 지켜 준다. 테스트가 읽기 쉽고 빠르며 믿을 수 있으면, 코드를 고치는 일이 덜 두려워진다. 테스트 코드도 계속 다듬어야 하는 코드다. 제품 코드만큼은 아니더라도, 제품 코드를 지탱할 만큼의 품질은 유지해야 한다.

다음장으로 10장