9. 단위 테스트

Posted by yunki kim on January 20, 2022

TDD의 세가지 법칙

    1. 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.

    2. 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.

    3. 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.

  위 규칙을 따르면 개발과 테스트가 대략 30초 주기로 묶인다. 테스트 코드와 실제 코드가 함께 나오고 테스트 코드가 실제 코드보다 불과 몇 초 전에 나온다.

  이런 플로우를 가지고 코드를 작성하면 방대한 양의 테스트코드가 작성되고 실제 코드를 거의 다 테스트하는 테스트 케이스가 나온다. 하지만 이런 방대한 양의 테스트 코드는 심각한 관리 문제를 야기한다.

 

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

  테스트 코드는 깔끔해야 한다. TDD라는 개념이 없던 시절의 테스트 코드 또는 현대에 작성되고 있는 일부 테스트 코드들은 매우 지저분하다. 함수, 변수 등의 명명 규칙을 제대로 따르지 않고 단지 현재 코드가 정상적으로 작동 하는지 테스트 하기 위해 일회성으로 테스트 코드를 작성한다.

  이런 일회성 테스트 코드의 문제는 실제 구현 코드가 바뀔때 마다 테스트 코드가 변해야 한다는 대에 있다. 테스트 코드가 더러우면 실제 코드를 작성하는 시간 보다 테스트 코드를 추가하고, 수정하는 데에 더 많은 시간이 든다. 실제 코드를 변경해 테스트 케이스가 실패하기 시작하면 테스트 케이스를 통과하기 점점 어려워지고 나중엔 테스트 슈트가 폐기되게 된다.

  테스트 슈트 폐기는 더 큰 문제를 야기한다. 개발자는 자신이 작성한 코드가 정상적으로 작동되는 지를 확인해야 하는데 테스트 코드가 없으니 확인할 방법이 없다. 그로 인해 결함율이 높아지고 개발자는 변경을 주저한다. 결국 스파게티 코드가 되버린다.

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

 

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

  코드에 유연성, 유지보수성, 재사용성을 제공하는 버팀목이 단위 테스트이다. 테스트 케이스가 없으면 모든 변경 사항이 잠재적인 버그이다.

  테스트 커버리지가 높을 수록 더 과감히 코드를 개선할 수 있다. 실제 코드를 점검하는 자동화된 단위 테스트는 설계와 아키텍처를 최대한 깨끗하게 보존하는 열쇠다. 

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

 

깨끗한 테스트 코드

  깨끗한 테스트 코드를 만들기 위해서는 가독성에 집중해야 한다. 실제 코드를 작성할 때와 같이 테스트 코드 역시 명료성, 단순성, 풍부한 표현력이 필요하다. 테스트 코드는 최소한의 표현으로 많은 것들을 나타내야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
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 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);
}
 
public void testGetPageHieratchAsXmlDoesntContainSymbolickLinks()
        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.PROEPRTY_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 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);
}
 
public void testGetDataAsHtml() throw 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 FitNessContext(root), request);
    String xml = response.getContent();
 
    assertEquals("text/xml", response.getContentType());
    assertSubString("test page", xml);
    assertSubString("<Test", xml);
}
cs

  위 테스트 코드를 보자. 위 코드는 코드의 중복이 너무 많다. 또 한 pagePath는 웹 로봇(crawler)이 사용하는 객체다. 이 코드는 테스트와 무관하고 의도만 흐린다.  또한 이 코드는 온갖 잡다하고 무관한 코드가 존재하기 때문에 독자가 코드를 이해하기 어렵다.

  아래의 코드는 위와 같은 테스틑 수행한다. 그럼에도 아래의 코드가 더 깔끔하고 간단하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public void testGetPageHieratchyAsXml() throws Exception {
    makePages("PageOne""PageOne.ChildOne""PageTwo");
 
    submitRequest("root""type:pages");
 
    assertResponseIsXml();
    assertReponseContains("<name>PageOne</name>"
        "<name>PageTwo</name>""<name>ChildOne</name>");
}
 
public void testGetPageHieratchAsXmlDoesntContainSymbolickLinks()
        throws Exception {
    WikiPage page = makePage("PageOne");
    makePages("PageOne.ChildOne""PageTwo");
 
    addLinkTo(page, "PageTwo""SymPage");
 
    submitRequest("root""type:pages");
 
    assertResponseIsXML();
    assertReponseContains("<name>PageOne</name>"
        "<name>PageTwo</name>""<name>ChildOne</name>");
    assertResponseDoesNotContain("SymPage");
}
 
public void testGetDataAsHtml() throw Exception {
    makePageWitdhContent("TestPageOne""test page");
 
    submitRequest("TestPageOne""type:data");
 
    assertResponseIsXML();
    assertResponseContains("test page""<Test");
}
cs

  BUILD-OPERATE-CHECK Pattern이 위와 같은 테스트 구조에 적합하다. 각 테스트는 명확히 세 부분으로 나뉘어져 있다.

    1. 테스트 자료를 만든다

    2. 테스트 자료를 조작한다

    3. 조작한 결과가 올바른지 확인한다.

  테스트 코드는 본론에 돌입해 진짜 필요한 자료 유형과 함수만 사용한다. 따라서 코드를 읽는 사람은 잡다한 것들 때문에 헛갈릴 필요가 없다.

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

    위의 테스트 코드는 도메인에 특화된 언어(DSL)로 테스트 코드를 구현하는 기법을 보여준다. 시스템 조작 API를 사용하      는 대신 API 위에다 함수와 유틸리티를 구현한 후 그 함수와 유틸리티를 사용하기 때문에 테스트 코드를 짜고, 읽기 쉬워진    다. 이렇게 구현한 함수와 유틸리티는 테스트 코드에서 사용하는 특수한 API가 된다.

    이런 테스트 API는 첨음부터 설계된 API가 아닌 난잡한 코드를 계속 리펙토링하다가 진화된 API이다.

  이중 표준

    테스트 API에 적용되는 표준과 실제 코드에서 적용하는 표준은 다르다.테스트 코드는 테스트 환경에서 동작하기 때문에      실제 코드처럼 효율적일 필요가 없다. 즉, 코드가 CPU, 메모리 효율과 관련해 최적화가 필요한 경우 테스트 코드라면 이를    무시해도 된다. 오직 깔끔한 코드에만 초점을 두자.

 

테스트 당 assert 하나

  JUnit으로 테스트 코드를 작성할 때는 assert문이 테스트 하나 당 하나만 존재해야 한다. 그래야 결론이 하나라 코드를 빠르게 이해할 수 있다.

1
2
3
4
5
6
7
8
9
public void testGetPageHieratchyAsXml() throws Exception {
    makePages("PageOne""PageOne.ChildOne""PageTwo");
 
    submitRequest("root""type:pages");
 
    assertResponseIsXml();
    assertReponseContains("<name>PageOne</name>"
        "<name>PageTwo</name>""<name>ChildOne</name>");
}
cs

  위의 코드를 보자. 이 코드는 1. 출력이 XML이다. 2. 출력이 특정 문자열을 포함해야 한다. 라는 두 개의 결론을 가지고 있다. 또한 두 결론을 병합하기도 어렵다. 이 경우 아예 두 개의 테스트로 쪼개는 것이 적절하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void testGetPageHieratchyAsXml() throws Exception {
    givenPages("PageOne""PageOne.ChildOne""PageTwo");
 
    whenResponseIsIssued("root""type:pages");
 
    thenResponseShouldBeXml();
}
 
public void testGetPageHieratchyHasRightTags() throws Exception {
    givenPages("PageOne""PageOne.ChildOne""PageTwo");
 
    whenRequestIsIssued("root""type:pages");
 
    thenResponseShouldContain("<name>PageOne</name>"
        "<name>PageTwo</name>""<name>ChildOne</name>");
}
cs

  위에서는 함수 이름을 바꾸어서 given-when-then이라는 관례를 사용했다. 이 관례를 사용하면 코드를 읽기 쉬워진다. 그럼에도 코드 분리로 인해 중복이 발생했다. 중복을 제거하기 위해 TEAMPLATE METHOD pattern을 사용하자. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@BeforeEach()
void testGetPageHieratchy() {
    givenPages("PageOne""PageOne.ChildOne""PageTwo");
 
    whenResponseIsIssued("root""type:pages");
}
 
public void testGetPageHieratchyAsXml() throws Exception {
    thenResponseShouldBeXml();
}
 
public void testGetPageHieratchyHasRightTags() throws Exception {
    thenResponseShouldContain("<name>PageOne</name>"
        "<name>PageTwo</name>""<name>ChildOne</name>");
}
cs

  테스트 당 개념 하나

    테스트당 개념을 하나만 테스트하라. 여러 개념을 한 함수로 몰아 넣으면 독자가 각 절이 거기에 존재하는 이유와 각 절이 테스트 하는 개념을 모두 이해해야 한다.

 

깨끗한 테스트를 위한 규칙 - FIRST(Fast, Independent, Repeatable, Self-Validating, Timely)

  깨끗한 테스트는 다음과 같은 규칙을 따른다

  Fast:

    테스트는 빨라야 한다. 테스트가 느리면 초반에 문제를 찾기 위해 테스트를 자주 돌리지 못한다. 이는 결국 코드 품질을      저하시킨다.

  Independent:

    각 테스트는 독립적이고 무작위로 실행해도 통과되야 한다. 테스트가 하나 실패했을 때 나머지도 연달아 실패한다면 실패    의 원인을 찾기 어려워진다.

  Repeatable:

    테스트는 어떤 환경에서든 반복 가능해야 한다. 심지어 네트워크가 연결되지 않은 환경에서도 실행 가능해야 한다. 테스.    트가 돌아가지 않는 환경이 있따면 테스트가 실패한 이유를 둘러댈 변명이 생긴다. 또 한 환경이 지원되지 않는 이유로 테      스트를 실행하지 못하는 상황이 생긴다.

  Self-Validating:

    테스트는 오직 성공, 실패 둘 중 하나의 결과만 내야 한다. 통과 여부를 알기 위해 로그 파일을 읽거나 두 파일을 대조하는    등의 추가적인 작업을 하게 해서는 안된다.

  Timely:

    테스트는 적시에 작성해야 한다. 단위 테스트는 테스트를 하려는 실제 코드를 작성하기 직전에 만들어야 한다.

 

출처 - 클린 코드