혼자 정리

9장. Unit Test 본문

클린코드

9장. Unit Test

tbonelee 2021. 10. 28. 03:26

9장 단위 테스트(Unit Test)

The Three Laws of TDD

  1. 실패하는 유닛 테스트를 쓰기 전까지는 프로덕션 코드를 쓰지 않는다.
  2. 실패하는 유닛 테스트를 한 번에 하나 이상 작성하지 않는다.
  3. 현재 실패하는 유닛 테스트를 통과하기에 충분한 정도를 넘어서는 프로덕션 코드를 작성하지 않는다.

Keeping Tests Clean

빠른 테스트를 위해 보통은 테스트 코드를 클린 코드로 짤 생각을 하지 않는다.

하지만 dirty tests는 없는 것보다 나을 것이 없다.

테스트를 지금만 할 것이 아니기 때문이다.

프로덕션 코드도 지속적으로 수정이 될 것이고 그 때마다 더러운 테스트 코드를 고치는데 걸리는 시간은 계속 늘어나게 된다.

그렇다고 테스트를 하지 않으면 프로덕션 코드 일부분의 수정으로 시스템이 무너지지 않는다는 것을 확인할 방법이 없다. 결국 코드 수정으로 시스템 결함이 자주 발생하고 코드를 수정하는 것 자체를 두려워하게 된다.

그러니 테스트 코드를 프로덕션 코드와 같은 중요도로 생각하자

Tests Enable the -ilities

테스트 코드를 클린하게 유지하지 않으면 쓰기 힘드니 버리게 되고, 그러면 프로덕션 코드를 유연하게 수정하기 힘들어진다.

즉, 유닛 테스트가 코드를 유연, 지속 가능, 재사용 가능하게 만든다.

아무리 구조를 잘 짜고 디자인을 잘 하더라도 유닛 테스트가 없으면 버그 걱정에 코드 수정을 걱정할 수 밖에 없다.

그러니 지속적으로 코드를 개선시키고 싶으면 유닛 테스트를 깨끗하게 유지 잘 하자.

Clean Tests

클린 테스트는 무조건 '가독성'이다. 프로덕션 코드에서보다 더 강조된다.

가독성은 단순, 명료, 표현의 밀도로 결정된다. 가능한 적은 표현으로 많은 것을 말하게 하자.

다음은 가독성이 좋지 않은 테스트 코드

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);
}

가독성을 위해 다음과 같이 고칠 수 있다.

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 패턴이 명확해졌다.

각 테스트는 세 파트로 나뉜다.

첫번째 파트에서 테스트 데이터를 빌드하고, 두번째 파트에서 테스트 데이터에 작동하고, 세번째 파트에서 작동 결과가 기대한대로 나왔는지 체크한다.

쓸데없는 디테일들은 빼야한다. 그래야 누가 읽더라도 무슨 일을 하는 테스트 코드인지 쉽게 파악할 수 있다.

Domain-Specific Testing Language

앞의 테스트 코드는 테스트 코드가 해당 테스트 도메인에 특화된 언어를 만들게 되는지 보여준다.

시스템 조작에 사용되는 API를 직접 쓰기보다 함수 같은 것을 작성해서 테스트를 더 쓰기와 읽기에 편하게 만들었다. 이러한 함수들을 테스트에 특화된 API로 볼 수 있다.

이러한 테스트 API는 미리 만들어지기 보다는 너무 복잡하고 알아보기 힘든 테스트 코드를 리팩토링하면서 만들어진다.

그렇게 만든 테스트 API는 나중에 또 활용할 일이 있을 수도 있다.

A Dual Standard

테스트 코드를 돌리는 환경과 프로덕션 코드를 돌리는 환경은 다르다.

실제 프로덕션 코드를 돌릴 때는 메모리나 CPU 효율 문제가 있을 수 있다.

하지만 테스트 코드는 그럴 가능성이 더 적다. 따라서 가독성을 위해 효율성을 조금 더 포기할 수도 있다.

One Assert per Test

테스트 코드의 각 함수마다 assert문을 단 하나만 사용해야 한다고 주장하는 사람도 있다.

assert문이 단 하나인 함수는 결론이 하나라서 코드를 이해하기 쉽고 빠르다.

(항상 그렇게 하지는 못하더라도 최대한 줄여보자.)

Single Concept per Test

테스트 하나에 하나의 개념만 존재하는 것이 이해하기 편하다.

/**
* Miscellaneous tests for the addMonths() method.
*/
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());
}

위의 코드는 독자적 개념 세 개를 테스트하므로 세 개로 쪼개는 것이 낫다.

결론적으로 '개념당 assert문을 최소한으로 쓰고', '테스트 함수 하나에 개념 하나만 테스트 하라'는 것이 좋은 규칙이 되겠다.

F.I.R.S.T

깨끗한 테스트를 위한 다섯 가지 규칙의 앞 글자를 따면 F.I.R.S.T.가 된다.

Fast

테스트는 빨라야 자주 돌려서 초반에 문제를 찾아내고 고칠 수 있다.

Independent

테스트는 독립적으로 짜서 어느 순서로 실행해도 괜찮아야 한다.

테스트가 서로 의존하면 하나가 실패할 때 나머지도 실패하므로 원인을 진단하기 어렵고, 후반 테스트가 제대로 이뤄지지 않으므로 후반 테스트가 찾아야 할 버그가 가려지게 된다.

Repeatable

실제 환경, QA 환경, 오프라인 환경 등 어디서도 돌아가야 테스트가 실패한 이유에 대한 변명을 만들지 않을 수 있다.

Self-Validating

성공 아니면 실패.

결과 파악을 위해 로그를 읽게 하거나 파일 두 개를 diff로 비교하게 하면 안된다.

테스트가 스스로 결과를 내놓지 않으면 주관적으로 판단하기 쉽고 일일이 직접 평가해야 한다.

Timely

딱 필요한 때 작성해야 한다.

단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현한다.

실제 코드 구현후 테스트 코드를 만들면 실제 코드는 테스트하기 너무 어렵다는 것을 나중에서야 알게 될 수 있다. 아니면 테스트가 불가능하게 실제 코드를 짰을 수도 있다.

'클린코드' 카테고리의 다른 글

[클린코드] 10. 클래스  (2) 2021.11.02
7. 오류 처리  (0) 2021.10.12
6. 객체와 자료구조  (0) 2021.10.10
5. 형식 맞추기  (0) 2021.10.06
4. 주석  (0) 2021.09.27