Programming/Clean Code

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

JaeHoney 2022. 5. 10. 22:58

개요.

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

하지만 많은 개발자들이 테스트 코드 작성을 급하게 서두르면서 제대로 된 테스트 케이스 작성을 놓치고 있다.

 

제대로 된 좋은 테스트 케이스를 작성하는 것에 대해 알아보자

TDD 법칙 세 가지

지금은 TDD가 실제 코드를 짜기 전에 단위 테스트부터 작성하라고 요구한다. 아래는 TDD가 추구하는 세 가지 원칙이다.

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

위 세가지 규칙을 지키면 개발과 테스트가 대략 30초 주기로 묶인다. 추가적으로 테스트 코드와 실제 코드가 함께 나올뿐더러 테스트 코드가 실제 코드보다 불과 몇 초 전에 나온다.

 

문제는 그렇게 일하면 매일 수십 개, 매달 수백 개, 매년 수천 개의 테스트 케이스가 나온다. 결국에는 실제 코드를 사실상 전부 테스트하는 테스트 케이스가 나오게 됩니다. 방대한 테스트 코드는 심각한 관리 문제를 유발할 수 있다.

지저분한 테스트 코드

테스트 코드가 지저분하면 코드를 변경할 떄 많은 테스트 코드를 수정해야 한다. 그리고 테스트 코드는 더 늘어난다. 점차 테스트 코드는 개발자 사이에서 큰 불만으로 자리잡고 비난받는다. 결국 테스트 코드는 폐기해야하는 상황에 처한다.

 

하지만 테스트 케이스가 없으면 개발자는 자신이 수정한 코드가 제대로 도는지 확인할 수 없다. 시스템 이쪽을 수정했을 때 저쪽이 안전하다는 사실을 검증할 수 없고 결함율이 높아지기 시작한다. 추가로 변경을 하면 테스트 케이스가 깨진다는 생각에 변경을 주저하게 된다.

 

그러면서 코드가 망가지고 뒤섞인 코드와 테스트에 쏟아 부은 노력이 허사였다는 실망감만 남는다. 그들이 테스트에 쏟아 부은 노력은 확실히 허사가 되었다. 하지만, 테스트 코드는 잘못이 없다. 지저분한 테스트 코드를 작성한 개발자의 잘못이다.

테스트 코드의 순기능

테스트는 유연성과 유지보수성과 재사용성을 제공한다. 단위 테스트를 잘 작성하면 변경이 두렵지 않게 된다. 단위 테스트가 작성되면 이쪽을 수정했을 때 저쪽이 안전하다는 사실을 신뢰할 수 있다.

 

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

 

테스트 커버리지가 높을수록 공포는 줄어들게 된다.

 

좋은 테스트 코드는 실제 코드 만큼이나 중요하다. 깨끗하게 짜야 한다.

깨끗한 테스트 코드

깨끗한 테스트 코드를 만들기 위해서 가장 중요한 것이 가독성이다. 테스트 코드의 가독성을 높이기 위해서 BUILD-OPRATE-CHECK 패턴(이하 Given-When-Then)을 사용한다.

@Test
public void isOffline(String location, boolean isOffline) {
    // Given
    Event event = Event.builder()
            .location("강남")
            .build();

    // When
    event.update();

    // Then
    assertThat(event.isOffline()).isEqualTo(isOffline);
}

해당 패턴을 사용하면 코드가 명확히 세 부분으로 나눠져서 가독성이 충분히 향상된다.

  • BUILD - 테스트를 위한 상황을 만든다.
  • OPERATE - 테스트를 하기 위한 기능을 수행한다.
  • CHECK - 수행한 결과가 올바른지 확인한다.

더불어서 아래의 테스트 코드를 보자.

public void testGetPageHieratchyAsXml() throws Exception {
    // Given
    crawler.addPage(root, PathParser.parse("PageOne"));
    crawler.addPage(root, PathParser.parse("PageOne.ChildOne"));
    crawler.addPage(root, PathParser.parse("PageTwo"));
    
    // When
    request.setResource("root");
    request.addInput("type", "pages");
    Responder responder = new SerializedPageResponder();
    SimpleResponse response =
        (SimpleResponse) responder.makeResponse(new FitNesseContext(root), request);
    String xml = response.getContent();
    
    //Then
    assertEquals("text/xml", response.getContentType());
    assertSubString("<name>PageOne</name>", xml);
    assertSubString("<name>PageTwo</name>", xml);
    assertSubString("<name>ChildOne</name>", xml);
}

패턴을 분리한 부분은 좋았지만, PathParser를 보면 굳이 테스트 코드에 노출할 필요가 없는 코드이다. 이런 코드는 테스트 코드의 의도만 흐리게 된다. Responder 객체를 생성해서 request를 요청하고 결과를 수집하는 코드 역시 노출할 필요가 없다.

 

이러한 부분을 테스트 코드로 노출하면 잡다하고 무관한 코드를 이해하면서 에너지를 쏟아야지 테스트 케이스를 이해할 수 있다. 따라서 낭비 및 생산성 저하로 이어질 수 있다.

 

위 테스트는 메서드를 추출해서 아래와 같이 리팩토링할 수 있다.

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

그 결과 목적과 의도가 그대로 드러나는 간결한 테스트 코드가 되었다.

 

DSL(도메인에 특화된 언어)로 테스트 코드를 구현하면서 시스템 조작 API 대신 API 위에 함수와 유틸리티를 구현한 후 사용하므로 테스트 코드를 짜기도 읽기도 쉬워진다. 해당 함수와 유틸리티는 같은 도메인 다른 테스트 코드에서도 재활용할 수 있다!

 

이러한 테스트 API는 처음부터 설계된 API가 아니다. 잡다하고 세세한 사항에 대해 지속적으로 관심을 기울이고 리팩토링하며 진화된 API이다. 초기에 설계를 잘하는 것도 좋지만 기존의 코드의 문제점을 빨리 발견하는 것도 중요하다.

테스트당 개념 하나

테스트당 개념 하나만 테스트하는 것이 좋다. 가령, 아래와 같은 테스트는 테스트를 분리할 필요가 있다.

/**
 * addMonth() 메서드를 테스트하는 장황한 코드
 */
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

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

 

Fast

  • 테스트는 빨라야 한다.
  • 느리면 자주 돌리기 꺼려진다. -> 초반에 문제를 확인하기 힘들다.
  • 코드를 마음껏 정리하지도 못한다.

Independent

  • 테스트는 서로 의존해서는 안된다. -> 나중에 원인을 진단하기 어렵다.
  • 테스트는 독립적이으로 실행되어야 한다.
    • 인터넷 연결이 안되어도 테스트는 성공해야 한다.
    • 테스트 결과가 외부 서버 등의 영향을 받아서는 안된다.
  • 어떤 순서로 실행해도 괜찮아야 한다.

Repeatable

  • 테스트는 어떤 환경에서도 실행 가능해야 한다.
    • 가령, 네트워크가 연결되지 않은 환경에서도 실행이 가능해야 한다.
    • 환경에 제약이 없어야 테스트에 대한 신뢰를 얻을 수 있기 때문
  • 테스트는 어떤 환경에서도 반복 가능해야 한다.

Self-Validating

  • 테스트는 Boolean 값으로 결과를 내야 한다. (성공 또는 실패)
  • 자체적으로 수행의 결과를 출력해서 비교하거나 주관이 포함되어선 안된다.

Timely

  • 테스트는 적시에 작성해야 한다.
  • 단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현한다.
    • 실제 코드를 먼저 구현하면 테스트 코드 작성이 어렵다고 여겨질 지 모른다.
    • 테스트가 불가능하게 실제 코드를 설계할지도 모른다.

 


Reference