Study/CleanCode

[클린코드] 9장. 단위 테스트

Wonol 2023. 11. 5. 14:03
반응형

클린코드(CleanCode)를 읽고 간략하게 정리한 글입니다.


9장. 단위 테스트

- 우리들 대다수에게 단위 테스트란 자기 프로그램이 '돌아간다'는 사실만 확인하는 일회성 코드에 불과했다.

- 에자일과 TDD 덕택에 단위 테스트를 자동화하는 개발자들이 이미 많아졌으며 점점 더 늘어나는 추세이다.

- 테스트를 추가하려고 급하게 서두르는 와중에 제대로 된 테스트 케이스를 작성해야 한다는 더욱 중요한 사실을 놓치고 있다.

1. TDD 법칙 세 가지

- TDD(Test Driven Development) : 테스트 주도 개발(실제 코드를 작성하기 전에 단위 테스트부터 작성하라)

  • 첫째 법칙 : 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.
  • 둘째 법칙 : 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
  • 셋째 법칙 : 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.

- 위 규칙을 따라 일을 한다면 매일 수백, 수천 개에 달하는 테스트 케이스가 나온다.

- 실제 코드를 사실상 전부 테스트하는 테스트 케이스가 나온다.

- 그렇지만 실제 코드와 맞먹을 정도로 방대한 테스트 코드는 심각한 관리 문제를 유발하기도 한다.

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

- 테스트 코드가 지저분할 경우 실제 코드가 변경 시에 원활하게 대응할 수 없게 된다.

- 그러면 실제 코드 변경시 실제 코드를 짜는 시간보다 테스트 케이스 수정에 더 많은 시간이 걸린다.

- 이는 테스트 케이스를 점점 더 통과시키기 어려워지며 결국 테스트 코드를 폐기해야 하는 상황이 발생하게 된다.

- 결국 코드 수정 시 다른 쪽이 안전하다는 사실을 검증할 수 없게 되어 변경을 주저하게 되고 그러면서 코드는 지저분해진다.

- 테스트 코드는 실제 코드 못지않게 중요하다.

- 테스트 코드는 사고와 설계와 주의가 필요하다. 실제 코드 못지 않게 깨끗하게 짜야한다.

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

- 테스트 코드를 깨끗하게 유지하지 않으면 결국은 잃어버린다.

  • 테스트 코드가 지저분하면 코드를 변경하는 능력이 떨어지며 코드 구조를 개선하는 능력도 떨어진다.

- 테스트 케이스가 없으면 실제 코드를 유연하게 만드는 버팀목이 사라진다.

  • 테스트가 없으면 모든 변경이 잠정적인 버그이다.
  • 아키텍처가 아무리 유연하더라도 테스트 케이스가 없으면 개발자는 변경을 주저한다.

- 테스트 케이스가 있다면 공포는 사실상 사라진다.

  • 테스트 케이스가 검증을 해주기 때문에 코드 변경이 쉬워진다.

- 코드에 유연성, 유지 보수성, 재사용성을 제공하는 버팀목이 바로 단위 테스트이다.

3. 깨끗한 테스트 코드

- 깨끗한 테스트 코드를 만들려면? 세 가지가 필요하다. -> 가독성! 가독성! 가독성!

- 어쩌면 실제 코드보다 가독성이 더 중요하다.

- 이를 위해 명료성, 단순성, 풍부한 표현력이 필요하다.

- 테스트 코드에는 최소의 표현으로 최대한 많은 것을 나타내야 한다.

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 패턴을 적용한 것이다.

  1. 첫 부분은 테스트 자료를 만든다.
  2. 두 번째 부분은 테스트 자료를 조작한다.
  3. 세 번째 부분은 조작한 결과가 올바른지 확인한다.

- 테스트 코드는 본론에 돌입해 진짜 필요한 자료 유형과 함수만 사용한다.

3-1. 도메인에 특화된 테스트 언어

- 바로 위의 코드는 도메인의 특화된 언어(DSL)로 테스트 코드를 구현하는 기법을 보여준다.

- 테스트를 구현하는 당사자와 나중에 테스트를 읽어볼 독자를 도와주는 테스트 언어이다.

- 이러한 테스트 API는 처음부터 설계된 API가 아니다. 잡다하고 세세한 사항으로 범벅된 코드를 계속 리팩터링 하다가 진화된 API이다.

3-2. 이중 표준

- 테스트 API 코드에 적용하는 표준은 실제 코드에 적용하는 표준과 확실히 다르다.

- 단순하고, 간결하고 표현력이 풍부해야 하지만, 실제 코드만큼 효율적인 필요는 없다. -> 테스트 환경에서 돌아가는 코드이기 때문이다.

// Bad
@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());
}

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

 

4. 테스트 당 assert 하나

- 함수마다 assert 문을 단 하나만 사용해야 한다고 주장하는 사람들도 있다.

  • 가혹한 규칙이라 여길지도 모르지만 확실히 장점이 있다.
  • assert 문이 단 하나인 함수는 결론이 하나이기 때문에 코드를 이해하기 쉽고 빠르다.

- 개념이 같다면 한 함수에 assert를 여러 개 사용해도 괜찮다.

  • 단지 assert 문의 개수를 최대한 줄여야 한다는 것에는 동의한다.
  • 테스트 함수마다 한 개념만 테스트해야 한다.

4-1. 테스트당 개념 하나

- 위의 테스트 당 assert 하나 보다는 테스트 함수마다 한 개념만 테스트하라 라는 규칙이 더 낫다.

- 이것저것 잡다한 개념을 연속으로 테스트하는 긴 함수는 피한다.

- 가장 좋은 규칙은 "개념 당 assert 문 수를 최소로 줄여라"와 "테스트 함수 하나는 개념 하나만 테스트하라"라 이다.

5. F.I.R.S.T

- 깨끗한 테스트는 다음 다섯 가지 규칙을 따르는데, 각 규칙에서 첫 글자를 따오면 FIRST 가 된다.

  1. 빠르게(Fast)
    - 테스트는 빨라야 한다. -> 테스트는 빨리 동작해야 한다는 말이다.
    - 테스트가 느리면 자주 돌릴 엄두를 못 낸다.
    - 자주 돌리지 않으면 초반에 문제를 찾아내 고치지 못한다.
    - 코드를 마음껏 정리하지도 못한다. -> 결국 코드 품질이 망가지기 시작한다.
  2. 독립적으로(Independent)
    - 각 테스트를 서로 의존하면 안 된다.
    - 한 테스트가 다음 테스트가 실행될 환경을 준비해서는 안 된다.
    - 각 테스트를 독립적으로 그리고 어떤 순서로 실행해도 괜찮아야 한다.
    - 테스트가 서로에게 의존하면 하나가 실패할 때 나머지도 잇달아 실패하므로 원인을 진단하기 어려워지며 후반 테스트가 찾아내야 할 결함이 숨겨진다.
  3. 반복가능하게(Repeatable)
    - 테스트는 어떤 환경에서도 반복 가능해야 한다.
    - 실제 환경, QA 환경, 버스를 타고 집으로 가는 길에 사용하는 노트북 환경(네트워크가 연결되지 않은)에서도 실행할 수 있어야 한다.
    - 테스트가 돌아가지 않는 환경이 하나라도 있다면 테스트가 실패한 이유를 둘러댈 변명이 생긴다.
    - 게다가 환경이 지원되지 않기에 테스트를 수행하지 못하는 상황에 직면한다.
  4. 자가검증하는(Self-Validating)
    -  테스트는 bool 값으로 결과를 내야 한다. -> 성공 아니면 실패이다.
    - 통과 여부를 알리고 로그 파일을 읽게 만들어서는 안 된다.
    - 통과 여부를 보려고 텍스트 파일 두 개를 수작업으로 비교하게 만들어서도 안된다.
    - 테스트가 스스로 성공과 실패를 가늠하지 않는다면 판단은 주관적이 되며 지루한 수작업 평가가 필요하게 된다.
  5. 적시에(Timely)
    - 테스트는 적시에 작성해야 한다. -> 단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현한다.
    - 실제 코드를 구현한 다음에 테스트 코드를 만들면 실제 코드가 테스트하기 어렵다는 사실을 발견할지도 모른다.
    - 어떤 실제 코드는 테스트하기 너무 어렵다고 판명 날지 모른다.
    - 테스트가 불가능하도록 실제 코드를 설계할지도 모른다.

5. 결론

- 테스트 코드는 실제 코드만큼이나 프로젝트 건강에 중요하다.

- 어쩌면 실제 코드보다 중요할 수 있다.

- 테스트 코드는 실제 코드의 유연성, 유지 보수성, 재사용성을 보존하고 강화하기 때문이다.

- 테스트 코드를 지속적으로 깨끗하게 관리하라 -> 표현력을 높이고 간결하게 정리하라.

반응형