Clean Code 9. Unit Test


TDD (Test Drived Development) 법칙 세 가지

  1. 실패하는 Unit Test를 작성할 때까지 실제 코드를 작성하지 않는다.
  2. 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 Unit Test를 작성한다.
  3. 현재 실패하는 Test를 통과할 정도로만 실제 코드를 작성한다.

이렇게 작업하면 개발과 테스트가 대략 30초 주기로 묶인다. 이러면 실제 코드를 사실상 전부 테스트하는게 가능하다. 하지만 방대한 테스트 코드는 심각한 관리 문제를 유발하기도 한다.

깨끗한 테스트 코드 유지하기

테스트 코드는 실제코드 못지 않게 중요하다. 실제 코드와 동일하게 품질에 신경써야 한다. 실제 코드가 계속 변하면 테스트 코드도 변해야 한다. 테스트 코드가 복잡하면 실제 코드를 수정하는데 걸림돌이 된다.

테스트는 유연성, 유지보수성, 재사용성을 제공한다.

테스트가 있아서 안심하고 아키텍쳐, 디자인를 개선할 수 있다. 따라서 테스트 코드가 지저분하면 코드를 변경하는 능력도 떨어진다.

깨끗한 테스트 코드

테스트 코드에서 필요한 건 가독성이다. 가독성을 높일려면 명료성, 단순성, 풍부한 표현력이 필요하다.

아래 테스트 코드는 개선이 필요하다.

FitNess의 SerializedPageResponderTest.java
public void testGetPageHieratchyAsXml() throws Exception {
    crawler.addPage(root, PathParser.parse("PageOne"));
    crawler.addPage(root, PathParser.parse("PageOne.ChildOne"));
    crawler.addPage(root, PathParser.parse("PageTwo"));

    request.setResource("root");
    request.addInput("type", "pages");
    Responder responder = new SerializedPageResponder();
    SimpleResponse response =
        (SimpleResponse) responder.makeResponse(new FitNesseContext(root), request);
    String xml = response.getContent();

    assertEquals("text/xml", response.getContentType());
    assertSubString("<name>PageOne</name>", xml);
    assertSubString("<name>PageTwo</name>", xml);
    assertSubString("<name>ChildOne</name>", xml);
}

public void testGetPageHieratchyAsXmlDoesntContainSymbolicLinks() throws Exception {
    WikiPage pageOne = crawler.addPage(root, PathParser.parse("PageOne"));
    crawler.addPage(root, PathParser.parse("PageOne.ChildOne"));
    crawler.addPage(root, PathParser.parse("PageTwo"));

    PageData data = pageOne.getData();
    WikiPageProperties properties = data.getProperties();
    WikiPageProperty symLinks = properties.set(SymbolicPage.PROPERTY_NAME);
    symLinks.set("SymPage", "PageTwo");
    pageOne.commit(data);

    request.setResource("root");
    request.addInput("type", "pages");
    Responder responder = new SerializedPageResponder();
    SimpleResponse response =
        (SimpleResponse) responder.makeResponse(new FitNesseContext(root), request);
    String xml = response.getContent();

    assertEquals("text/xml", response.getContentType());
    assertSubString("<name>PageOne</name>", xml);
    assertSubString("<name>PageTwo</name>", xml);
    assertSubString("<name>ChildOne</name>", xml);
    assertNotSubString("SymPage", xml);
}

public void testGetDataAsHtml() throws Exception {
    crawler.addPage(root, PathParser.parse("TestPageOne"), "test page");

    request.setResource("TestPageOne"); request.addInput("type", "data");
    Responder responder = new SerializedPageResponder();
    SimpleResponse response =
        (SimpleResponse) responder.makeResponse(new FitNesseContext(root), request);
    String xml = response.getContent();

    assertEquals("text/xml", response.getContentType());
    assertSubString("test page", xml);
    assertSubString("<Test", xml);
}
  1. addPage와 assertSubString을 부르느라 중복되는 코드가 매우 많다.
  2. 자질구레한 사항이 너무 많아 테스트 코드의 표현력이 떨어진다. PathParser는 문자열을 pagePath 인스턴스로 변환한다. responder 객체를 생성하는 코드와 response를 수집해 변환하는 코드, resource와 인수에서 요청 URL을 만드는 코드도 보인다. 이 코드는 테스트와 무관하며 테스트 코드의 의도만 흐린다.
  3. 이 코드는 테스트와 무관하며 테스트 코드의 의도만 흐린다. 온갖 테스트와 무관한 코드들을 다 이해해야 테스트를 이해 할 수 있게된다.
SerializedPageResponderTest.java (refactored)
public void testGetPageHierarchyAsXml() throws Exception {
    makePages("PageOne", "PageOne.ChildOne", "PageTwo");

    submitRequest("root", "type:pages");

    assertResponseIsXML();
    assertResponseContains(
        "<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>");
}

public void testSymbolicLinksAreNotInXmlPageHierarchy() throws Exception {
    WikiPage page = makePage("PageOne");
    makePages("PageOne.ChildOne", "PageTwo");

    addLinkTo(page, "PageTwo", "SymPage");

    submitRequest("root", "type:pages");

    assertResponseIsXML();
    assertResponseContains(
        "<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>");
    assertResponseDoesNotContain("SymPage");
}

public void testGetDataAsXml() throws Exception {
    makePageWithContent("TestPageOne", "test page");

    submitRequest("TestPageOne", "type:data");

    assertResponseIsXML();
    assertResponseContains("test page", "<Test");
}

BUILD-OPERATE-CHECK 패턴으로 명확히 세 부분으로 나눠진다.

  1. 테스트 자료를 만든다.
  2. 테스트 자료를 조작한다.
  3. 결과가 올바른지 확인한다.

이중 표준

테스트 코드와 실제 코드를 같은 기준으로 표현할 필요는 없다. 테스트 코드는 단순하고, 간결하고, 표현력이 풍부해야 하지만, 실제 코드만큼 효율덕일 필요는 없다.

EnvironmentControllerTest.java
@Test
public void turnOnLoTempAlarmAtThreashold() throws Exception {
    hw.setTemp(WAY_TOO_COLD); 
    controller.tic(); 
    assertTrue(hw.heaterState());   
    assertTrue(hw.blowerState()); 
    assertFalse(hw.coolerState()); 
    assertFalse(hw.hiTempAlarm());       
    assertTrue(hw.loTempAlarm());
}

온도가 내려갔을 때 최종 상태를 체크하는 코드이다. 이 코드를 볼려면 heatState를 보고 왼쪽의 True를 봐야하고 coolerState를 보고 왼쪽의 False를 봐야한다. 테스트 코드를 읽기 어렵다. 그래서 다음과 같이 수정하였다.

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

wayTooCold에 tic을 실행하는 것까지 넣었고, getState함수를 만들어서 각 상태들에 대해서 대문자는 True, 소문자는 False를 의미하도록 문자열믄 만들어서 반환한다. 실제 코드로 쓰기에는 그릇된 정보를 피하라는 규칙을 위반하지만 여기서는 적절해 보인다. 여기에서 만든 getState 함수는 다음과 같다.

public String getState() {
    String state = "";
    state += heater ? "H" : "h"; 
    state += blower ? "B" : "b"; 
    state += cooler ? "C" : "c"; 
    state += hiTempAlarm ? "H" : "h"; 
    state += loTempAlarm ? "L" : "l"; 
    return state;
}

효율성을 높일려면 StringBuffer가 더 적합하다. 하지만 이게 더 보기 편하다. 이것이 이중 표준의 본질이다.

테스트당 assert 하나

가혹한 규칙이라 여길지도 모르지만, EnvironmentControllerTest.java 를 보면 확실히 장점이 있다. 그럼 SerializedPageResponderTest.java 는 어떨까 ? 2개의 assert를 2개로 쪼개는 방법이 있다.

public void testGetPageHierarchyAsXml() throws Exception { 
    givenPages("PageOne", "PageOne.ChildOne", "PageTwo");

    whenRequestIsIssued("root", "type:pages");

    thenResponseShouldBeXML(); 
}

public void testGetPageHierarchyHasRightTags() throws Exception { 
    givenPages("PageOne", "PageOne.ChildOne", "PageTwo");

    whenRequestIsIssued("root", "type:pages");

    thenResponseShouldContain(
        "<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"
    ); 
}

함수 이름을 바꿔 given-when-then 관례를 사용했다. 테스트를 분리하면 중복 코드가 많아지는데 TEMPLATE-METHOD 패턴을 사용하면 중복을 제거 할 수 있다. given/when 부분을 부모 클래스에 만들어 두고 then을 자식 클래스에 두면 된다. 아니면 @Before에 두고 @Test에수 수행하는 방법도 있다. 하지만 이 케이스에는 그냥 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());

    SerialDate d4 = SerialDate.addMonths(1, SerialDate.addMonths(1, d1)); 
    assertEquals(30, d4.getDayOfMonth());
    assertEquals(7, d4.getMonth());
    assertEquals(2004, d4.getYYYY());
}

이를 3개로 분리하는게 가능하다.

  • 31일로 끝나는 경우
    • 30일로 끝나는 한 달을 더하면 날짜는 30일이 돠어야 한다.
    • 두 달을 더하고 두 번째 달이 31일로 끝나면 31일이 되어야 한다.
  • 30일로 끝나는 경우
    • 31일로 끝나는 한달을 더하면 30일이 되어야지31일이 되면 안된다.

이렇게 표현하면 규칙이 보인다. 원래 날짜보다 더 커질 수는 없다. 2월 28일에 한달을 더하면 3월 28일이 나와야 한다. 이 테스트도 추가를 해야 한다.

F.I.R.S.T

깨끗한 테스트는 다음 다섯 가지 규칙을 따라야 한다.

1. Fast (빠르게)

빨라야 자주 돌린다. 자주 돌리지 않으면 초반에 문제를 고치지 못한다.

2. Independent (독립적으로)

각 테스트는 다른 테스트에 의존하면 안된다. 서로 의존하면 하나가 실패시 나머지도 실패하므로 원인을 진단하기 어려워진다.

3. Repeatable (반복가능하게)

어떤 환경에서도 반복 가능해야 한다. 네트워크가 연결되지 않은 노트북에서도 실행할 수 있어야 한다. 안그러면 테스트가 실패한 이유를 둘러댈 변명이 생긴다.

4. Self-Validating (자가검증하는)

테스트는 bool 결과를 내야 한다. 스스로가 결과를 나타내야 한다. 로그 정보나 다른 파일을 열어봐야 한다면 판단이 주과적이 되며 지루한 수작업 평가가 필요하게 된다.

5. Timely (적시에)

테스트는 적시에 작성되야 한다. 실제 코드를 구현한 다음에 단위 테스트를 만들려면 실제 코드가 테스트하기 어렵다고 판명날 수도 있다. 그러면 테스트가 불가능한 실제 코드를 짜게 된다.

이 글이 도움이 되셨다면 공감 및 광고 클릭을 부탁드립니다 :)