클린 코드 - 9. 단위 테스트

이정우·2021년 12월 19일
0

Clean Code

목록 보기
9/10

1997년까지만 하더라도 TDD라는 개념은 아무도 알지 못했고, 단위 테스트는 프로그램이 단순하게 돌아간다는 사실만 확인하는 용도였다.
현대에 와서 애자일과 TDD로 인해 단위 테스트를 자동화하는 프로그래머는 많아졌지만, 제대로 된 테스트 케이스를 작성하는 방법은 잘 모르는 경우가 많다.

TDD 법칙 세 가지

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

이 규칙들을 따르면 테스트 코드와 실제 코드가 같이 나오며, 하루에도 수많은 테스트 케이스가 만들어진다.

하지만 수많은 테스트 코드는 관리가 어렵다는 단점도 존재한다.


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

테스트 코드에 실제 코드와 같은 품질을 적용하지 않는 경우

  • "지저분해도 빨리"
    • 변수 이름에 신경을 쓰지 않음
    • 테스트 함수는 간결하거나 비서술적
    • 테스트 코드는 돌아가기만 하면 됨

일회용 테스트 코드만 작성하다가 단위 테스트를 작성하는 것은 어렵게 느껴질 수 있다. 또한, 테스트를 하지 않는 것보다는 지저분한 테스트 코드라도 있는 것이 낫다는 생각이 들 수도 있다.

하지만 실제로는 지저분한 테스트 코드를 작성하는 것보다 테스트를 안하는 것이 더 낫다.

실제 코드가 변하면 그에 따라 테스트 코드도 변해야 한다. 하지만 테스트 코드가 지저분할 경우, 실제 코드를 작성하는 시간보다 테스트 케이스를 추가하는 시간이 더 길어지게 된다. 또한, 테스트 케이스를 통과시키기가 어려워지고, 테스트 코드는 계속해서 늘어만 가기 때문에 유지 보수 비용도 늘어난다.

이후, 천문학적인 유지 보수 비용으로 인해 테스트 슈트(테스트 유닛의 묶음)를 폐기해야만 하는 상황에 직면한다. 그렇다고 테스트 슈트를 없애버린다면 수정한 코드가 제대로 동작하는지 확인할 수가 없게 되어 결함율이 높아지고 코드가 망가진다.

결국 테스트 슈트도 없고, 코드는 엉망이고, 테스트에 쏟은 노력도 의미가 없었다는 실망감만 남게 된다.

하지만 처음부터 테스트 코드를 깨끗하게 작성했다면 노력이 허사로 돌아가지 않았을 것이다. 테스트 코드는 실제 코드 못지 않게 매우 중요하다.


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

코드에 유연성, 유지보수성, 재사용성을 제공하는 것은 단위 테스트이다. 테스트 케이스가 존재하지 않으면, 어디서 버그가 발생할지 모른다는 두려움에 코드의 수정이 어려워질 것이다.

하지만 테스트 케이스가 있다면, 설계가 엉망이더라도 큰 문제 없이 코드와 아키텍처 및 설계를 모두 개선할 수 있다.


깨끗한 테스트 코드

깨끗한 테스트 코드에서 가장 중요한 것은 가독성이다. 실제 코드에서보다 가독성이 더욱 중요하고, 가독성을 위해서는 최소의 표현으로 많은 것을 나타낼 수 있도록 명료성, 단순성, 풍부한 표현력이 필요하다.

public void testGetHieratchyAsXml() 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 FitNessContext(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);
}

PathParser는 문자열을 웹 크롤러가 사용하는 pagePath 인스턴스로 변환하는 객체이다. 이 코드는 테스트와는 무관하며 테스트 코드의 의도만 흐리게 만든다. responder와 response를 생성하는 부분 역시 가독성이 떨어진다.

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

수정한 코드는 세 부분으로 나눠진다.
1) 테스트 자료 생성
2) 테스트 자료 조작
3) 조작한 결과 검증

테스트와는 무관하여 필요 없는 코드를 모두 제거하여 진짜로 필요한 자료 유형과 함수만 사용하는 것에 주목하자. 코드를 읽는 사람은 처음 코드와는 다르게 코드가 수행하는 기능을 빨리 이해할 수 있게 된다.

도메인에 특화된 테스트 언어

수정한 코드는 도메인에 특화된 언어(DSL)로 테스트 코드를 구현하는 기법이다. 흔히 사용하는 API 대신, API 위에 함수와 유틸리티를 구현하여 사용하기 때문에 테스트 코드 작성이 쉬워진다.

이런 테스트 API는 처음부터 설계된 것이 아니라 리팩토링을 거쳐서 만들어진다. 숙련된 개발자가 되기 위해서는 간결하고 표현력이 풍부한 코드로 리팩토링할 수 있어야 한다.

이중 표준

테스트 코드는 가독성이 중요하긴 하지만, 실제 코드만큼 효율적일 필요는 없다. 실제 환경이 아니라 테스트 환경에서만 돌아가는 코드이기 때문이다.

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

위의 코드는 어떤 환경의 온도가 너무 낮아졌을 경우를 테스트한다. 각각의 상태를 보고, 값을 검증하는 부분으로 눈길을 돌려야하기 때문에 가독성이 떨어진다. 또한, tic 함수는 어떤 동작을 하는지 알 수 없다.

@Test
public void turnOnLoTempAlarmAtThreshold() throws Exception {
    wayTooCold();
    assertEquals("HBchL", hw.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;
}

먼저, tic 함수는 wayTooCold 함수로 숨겼고, 각각의 상태는 assertEquals 메소드 내 문자열로 옮겼다. 대문자는 On 상태를, 소문자는 Off 상태를 의미한다. 각각의 문자의 의미만 안다면 테스트 코드를 이해하기가 매우 쉬워진다.

getState는 StringBuilder나 StringBuffer를 사용하지 않고, 문자열에 직접 연산을 한다. 실제 환경에서는 자원이 한정적일 가능성이 높지만, 테스트 환경에서는 그럴 가능성이 낮을 것이다.

이중 표준은 실제 환경에서는 절대로 안 되지만 테스트 환경에서는 문제 없는 방식으로, 코드의 깨끗함과는 무관하다.


테스트 당 assert 하나

assert 문이 하나인 함수는 결론이 하나이기 때문에 코드를 이해하기 쉽고 빠르다. 하지만, 이러한 코드에서는 assert 문을 합하는 것이 비효율적이라고 느껴질 수도 있다. 그럴 때는 테스트를 두개로 쪼개 각각이 assert를 수행하면 된다.

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

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

    thenResponseShouldBeXML();
}

수정한 코드에서 given-when-then이라는 표현을 사용했는데, 이렇게 작성하면 코드를 읽기가 쉬워진다. 하지만 중복된 코드가 발생할 가능성이 높아지고, 중복을 없애기 위해 여러 방법을 사용하다 보면 배보다 배꼽이 커질 수 있다.

따라서 assert 문을 단 하나만 사용하기 보다는, 최대한 줄이는 방법이 오히려 좋을 수 있다.

테스트 당 개념 하나

독자적인 개념은 개념마다 테스트 함수를 만드는 것이 바람직하다. 여러 개념을 한 함수로 몰아넣으면 각 절의 존재 이유와 각 절이 테스트하는 개념을 모두 이해해야 하기 때문이다.


F.I.R.S.T

Fast

테스트는 빨라야 한다. 테스트가 느리면 자주 실행하지 못 하고, 초반에 문제를 찾아내기 어렵기 때문에 코드 품질이 망가지게 된다.

Independent

각 테스트는 서로 의존해서는 안 된다. 각 테스트는 독립적이어야 하며, 어떤 순서로 실행하든 괜찮아야 한다. 테스트가 서로 의존적이면 하나가 실패하면 나머지도 실패하기 때문에 원인을 찾기 어려워지기 때문이다.

Repeatable

테스트는 어떤 환경에서도 반복 실행할 수 있어야 한다. 테스트가 돌아가지 않는 환경이 있다면 테스트가 실패한 이유를 둘러댈 변명이 생기며, 테스트를 수행하지 못하는 상황도 발생한다.

Self-Validating

테스트는 boolean 값으로 결과를 내야 한다. 성공 아니면 실패, 두 가지의 결과만 나타나야 한다. 테스트 결과를 알기 위해 로그를 보거나 텍스트 파일 두 개를 비교하는 등 수작업이 필요하다면 판단은 주관적으로 변하게 된다.

Timely

테스트는 적시에 작성해야 한다. 단위 테스트는 실제 코드를 구현하기 직전에 구현해야 한다. 실제 코드를 먼저 구현한다면 테스트가 불가능할 수 있다.


결론

테스트 코드는 실제 코드 이상으로 중요하다. 유연성, 유지보수성, 재사용성을 보존하고 강화하는 역할을 수행하기 때문이다.

테스트 코드는 지속적으로 깨끗하게 관리하며, 표현력을 높이고 간결하게 정리해야 한다. 또한, 테스트 API를 구현하여 도메인 특화 언어를 만들면 테스트 코드를 작성하기 쉬워진다.

0개의 댓글