009. (Clean Code) 9. 단위 테스트 - Unit Tests

9. 단위 테스트 - Unit Tests

1997년만해도 TDD : Test-Driven Development 라는 개념이 없었다.

당시 대다수의 개발자들에게 단위 테스트란 내가 작성한 프로그램이 돌아간다는 사실만 확인하는 일회성 코드에 불과했다.

대개, 클래스와 메서드를 구현하는 데 많은 시간으 할애하고 임시 코드를 급조해 프로그램을 수동으로 실행하는 방식이었다.

이후 테스트 분야는 눈부신 성장을 이뤘다.

이제 테스트 케이스를 모두 구현하고 통과한 후에는 테스트 코드와 프로덕션 코드를 같은 경로에 패키징해두는 것이 일반적이 되었다.

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

이에 비례하여 테스트를 추가한다는 목적에 매몰되어, 제대로된 테스트 케이스를 작성해야하는 사실을 놓치는 경우도 빈번해졌다.

9.1. TDD 법칙 세 가지

TDD를 실제로 실천하지않더라도 실제 코드를 작성하기전에 단위 테스트부터 작성하라는 개념을 모르는 사람은 거의 없을 것이다.

하지만 이 개념은 TDD의 극히 일부이다.

아래 세 가지 법칙을 살펴보자.

첫 번째 법칙 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.

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

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

위 세 가지 법칙을 준수했을 때는 개발과 테스트가 대략 30초의 주기로 묶인다.

테스트 코드와 프로덕션 코드가 함께 생산되고, 테스트 코드가 프로덕션 코드보다 불과 몇 초전에 작성된다.

이를 습관하하면 프로덕션 코드를 사실상 전부 테스트하는 테스트 케이스가 나온다.

하지만 프로덕션 코드와 맞먹을 정도로 방대한 테스트 코드는 심각한 관리 문제와 유지보수 비용의 증가를 야기하기도 한다.

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

일회용 테스트 코드를 짜오다가 자동화된 단위 테스트 슈트를 작성하기란 쉽지 않다.

명심해야할 것은 지저분한 테스트 코드를 작성하는 건 테스트를 하지 않는 것과 다를 바 없다는 것이다.

문제는 프로덕션 코드가 변경되거나 개선되었을 때, 이를 테스트 코드도 쫓아가며 변경되어야한다.

이때 지저분한 테스트 코드일수록 변경하기는 더 어려워지고, 역설적으로 프로덕션 코드를 작성하는 시간보다 테스트 코드를 작성하는 시간이 더 걸리는 경우도 발생할 수 있다.

또한 프로덕션 코드의 변경으로 테스트 코드가 실패하는 경우, 이를 다시 통과시키는 것도 점점 더 어려워진다.

결국 테스트 코드는 개발자에게 점점 증가하는 부채로 다가오게 된다.

결국 테스트 코드를 포기하게 되고 이는 시스템의 안정성이 낮아지며 결함율이 높아지는 결과를 초래한다.

높은 결함율을 가진 시스템은 결국 개발자에게 변경을 주저시키는 요인으로 작용하여 완벽한 레거시 시스템으로 굳어진다.

따라서 테스트 코드는 프로덕션 코드 못지않게 깨끗하게 작성해야 한다.

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

테스트 케이스의 부재는 프로덕션 코드의 유연성을 제거하는 원인이다.

반대로 프로덕션 코드의 유연성, 유지보수성, 재사용성을 제공하는 것이 단위 테스트이다.

테스트 케이스가 없다면 사실 모든 변경이 잠재적인 버그라고 간주하게 되는 셈이다.

테스트 커버리지가 높을수록 지저분한 코드라도 별다른 우려사항 없이 변경을 하거나, 아키텍쳐를 개선할 수 있다.

그러므로 프로덕션 코드를 추종하는 자동화된 단위 테스트는 아키텍쳐를 보존하는 핵심이라고 볼 수 있다.

9.3. 깨끗한 테스트 코드

깨끗한 테스트 코드의 중요성은 잘 알앗다.

그럼 깨끗한 테스트 코드는 어떻게 만들 수 있을까?

여기서 꼭 필요한 것이 가독성이다.

어쩌면 프로덕션 코드보다 높은 가독성을 보장해야하는 것이 테스트 코드일 수도 있다.

테스트 코드는 높은 가독성을 바탕으로하여, 최소의 표현으로 많은 것을 나타내야한다.

아래 예제를 보자.

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
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());
ssertSubString("<name>PageOne</name>", xml);
ssertSubString("<name>PageTwo</name>", xml);
ssertSubString("<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);
}

위 예제의 가독성에 대해서 생각해보자.

먼저 addPage() 메서드와 assertSubString() 메서드를 호출하느라 중복되는 코드가 너무 많다.

그리고 자질구레한 사항이 너무 많아 표현력이 떨어지는 상태이다.

그럼 어떻게 개선할 수 있을까?

먼저 PathParser 클래스를 살펴보자.

PathParser는 문자열을 파싱하여 pagePath 인스턴스로 변환해주는 역할을 하며, pagePathcrawler가 사용하는 객체이다.

PathParser는 테스트의 목적과도 관련이 없고 표현력만 저하시키는 코드이다.

두번째로 responder 객체를 생성하는 코드와 response를 수집해 변환하는 코드 또한 표현력을 저하시키고 있다.

이를 개선해본다면 아래와 같은 코드가 될 것이다.

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
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 패턴이라고 한다.

각 테스트는 정확히 세 부분으로 나누어진다.

첫 번째, 테스트 자료를 만든다.

두 번째, 테스트 자료를 조작한다.

세 번째, 테스트 결과를 확인한다.

모든 단위 테스트에서 위와 같은 구조를 유지하면 높은 가독성을 유지할 수 있다.

참고 029. (Unit Test Principles) 3. 단위 테스트의 구조

9.4. 테스트당 assert 하나

JUnit으로 테스트 코드를 작성할 때에는 assert를 단 하나만 사용해야하나는 의견도 있다.

다소 가혹한 규칙으로 보일 수도 있겠지만 assert가 하나인 함수는 결론이 하나이기때문에 코드를 빠르고 쉽게 이해할 수 있다.

자세한 내용은 아래 포스팅을 참고하도록 하자.

참고 029. (Unit Test Principles) 3. 단위 테스트의 구조

9.5. FIRST

깨끗한 테스트는 아래 다섯가지 규칙을 따른다.

흔히 단위테스트의 FIRST 원칙이라고 불리는 것들이다.

  • Fast : 단위 테스트는 빠르게 실행되고, 결과도 빠르게 도출되어야 한다.
  • Isolate 혹은 Independent : 단위테스트는 격리되어, 그 자체로 독립적으로 실행되어야 한다.
  • Repeatable : 단위 테스트는 반복해서 실행할 수 있어야 한다.
  • Self-validating : 단위 테스트는 자체 검증이 가능해야 한다.
  • Timely 혹은 Thorough : 단위 테스트는 적절한 시기에 작성되어야 한다.

자세한 내용은 아래 포스팅을 참고하도록 하자.

참고 041. (Pragmatic Unit Testing in Kotlin with JUnit) 5. FIRST 원칙