[Clean Code] Ⅸ. 단위 테스트

Clean Code 3판을 읽고 정리한 글입니다

Ⅸ. 단위 테스트

1997이전

  • TDD 개념 없었음
  • 단위 테스트
    • 자기 프로그램이 돌아간다는 사실만 확인하는 일회성 코드
    • 클래스 메서드를 공들여 구현한 후, 임시 코드를 급조해 테스트 수행

현재

  • 테스트 코드 : 코드의 구현을 모두 확인하는 테스트 코드
  • 애자일과 TDD → 단위테스트 자동화 이미 많아짐
  • 급한 테스트 추가 + 제대로 된 테스트 케이스 작성을 놓침

TDD 법칙 3가지

3가지 법칙

  1. 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다
  2. 컵파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트 작성
  3. 현재 실패하는 테스트를 통과할 정도로만 실제 코드 작성
    → 3법칙 따르면 개발과 테스트가 대략 30초 주기
    → 테스트 코드가 실제 코드보다 불과 몇 초전
    → 매일 수십개, 매달 수백개, 매년 수천 개에 해당하는 테스트 케이스
    → 실제 코드를 사실상 전부 테스트 하는 테스트 케이스
    → 때로는 심각한 관리 무제 유발

깨끗한 코드 유지하기

테스트 코드는 지저분해도 빨리?

  • 실제 코드 진화 → 테스트도 진화 필요
  • 테스트 코드가 지저분하면 변경이 어려움 → 테스트 코드 추가/수정이 실제 코드 짜는 시간보다 오래걸림
  • 테스트 코드는 실제 코드 못지 않게 중요하다
  • 테스트 코드도 구현 코드처럼 깨끗하게 짜야 한다

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

  • 테스트 케이스가 있으면 변경에 대한 공포없이 안심하고 개선 가능
  • 테스트는 변경되는 설계와 아키텍처를 최대한 깨끗하게 보존하는 열쇠

Clean Test Code

  • 테스트 코드의 가장 중요한 점? 가독성
  • 실제코드보다 테스트 코드에서 가독성은 더 중요
  • 높은 가독성? → 명료성, 단순성, 붕푸한 표현력
SerializedPageResponderTest.java
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
58
59
60
 // FitNess에서 가져온 코드
// 아래 테스트 케이스 세 개는 이해하기 어렵기에 개선할 여지가 충분
// 첫째, addPage와 assertSubString을 부르느라 중복되는 코드가 매우 많다
// 좀 더 중요하게는 자질구레한 사항이 너무 많아 테스트 코드의 표현력이 떨어진다.
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);
}

문제점

  • addPage와 assertSubString을 부르느라 중복되는 코드가 매우 많다
  • PathParser
    • 문자열을 pagePath 인스턴스로 변환
    • pagePath 웹 로봇(크롤러)가 사용하는 객체 → 테스트와 무관하며 테스트 의도만 흐린다
  • Responder생성, respoonse 수집 코드 → 역시 잡음
  • resource의 인수에서 요청 URL 을 만드는 어설픈 코드
  • 읽는 사람을 고려하지 않음
SerializedPageResponderTest.java 9-2_리팩터링한 코드
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
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 패턴이 위와 같은 테스트 구조에 적합

  • 각 테스트는 명확히 세 부분으로 나눠진다.
  • 첫 부분은 테스트 자료를 만든다.
  • 두 번째 부분은 테스트 자료를 조작
  • 세 번째 부분은 조작한 결과가 올바른지 확인

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

  • 잡다하고 세세한 코드를 거의 다 없앰
  • 읽는 사람으로 하여금 테스트 코드가 수행하는 기능을 재빨리 이해

도메인 특화된 테스트 언어

  • 위의 리팩토링된 코드는 DSL로 테스트 코드 구현 기법 보여줌
  • 흔히 쓰는 시스템 조작 API 사용대신 API 위에 함수, 유틸리티 구현후 사용
  • 이렇게 구현한 함수와 유틸리티는 테스트 코드에서 사용하는 특수 API가 된다
    → 테스트 구현 당사자, 테스트를 읽어볼 독자를 도와주는 테스트 언어
  • 테스트 API는 처음부터 설계된 API가 아님 → 리팩터링하며 진화된 API

이중 표준

  • 테스트 API에 적용하는 표준은 실제 코드에 적용하는 표준과 확실히 다르다
  • 단순,간결,표현력 풍부는 동일
  • 실제 코드만큼 효율적일 필요는 없음 → 테스트 환경에서 돌아가는 코드이므로
EnvironmentControllerTest.java
1
2
3
4
5
6
7
8
9
10
11
12
//대충 읽어도 온도가 급격히 떨어지면 경보, 온풍기, 송풍기 확인하는 테스트 코드
@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());
}
//상태와 상태값 보느라 가독성 떨어짐
EnvironmentControllerTest.java(리팩터링)
1
2
3
4
5
@Test
public void turnOnLoTempAlarmAtThreshold() throws Exception {
wayTooCold();
assertEquals("HBchL", hw.getState());
}

설명

  • wayTooCold함수로 기존 tic함수 숨김
  • 그릇된 코드 위반에 가깝지만 여기에 적절 → 테스트 코드가 읽기 쉬어짐
EnvironmentControllerTest.java (bigger selection)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void turnOnCoolerAndBlowerIfTooHot() throws Exception {
tooHot();
assertEquals("hBChl", hw.getState());
}

@Test
public void turnOnHeaterAndBlowerIfTooCold() throws Exception {
tooCold();
assertEquals("HBchl", hw.getState());
}

@Test
public void turnOnHiTempAlarmAtThreshold() throws Exception {
wayTooHot();
assertEquals("hBCHl", hw.getState());
}

@Test
public void turnOnLoTempAlarmAtThreshold() throws Exception {
wayTooCold();
assertEquals("HBchL", hw.getState());
}
  • 테스트 코드 이해가 너무 쉬어짐
MockControlHardware.java
1
2
3
4
5
6
7
8
9
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;
}
  • 성능적으로 효율적이지 못한 코드
  • 효율을 높이려면 StringBuffer가 더 작합하지만 보기에 흉하다
  • 위 코드는 StringBuffer를 사용하지 않아 치루는 비용이 미미→ 테스트에선 자원 제한일 가능성이 낮으므로

이중 표준의 본질

  • 실제 환경에서는 절대 안되지만 테스트 환경에서는 전혀 문제 없는 방식이 존재
  • 대게 메모리,cpu효율 관련
  • 클린 코드 여부와는 철저히 무관

테스트당 assert 하나

테스트 코드시 함수마다 assert 하나만 사용?

  • 그래야 한다는 파 존재
  • 확실한 장점이 있음 → 결론이 하나라 코드가 이해하기 쉽고 빠름
  • 모두 적용되지 않음
  • 위 9-2_리팩터링 코드는
  • “출력이 XML”이다라는 assert와
  • “특정 문자열을 포함”이라는 assert가 있으며
  • 이 둘을 하나로 병합하는 방식이 불합리해보임
  • 방법 : 테스트를 쪼개 각자가 assert 수행
SerializedPageResponderTest.java (단일 Assert)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void testGetPageHierarchyAsXml() throws Exception { 
givenPages("PageOne", "PageOne.ChildOne", "PageTwo");

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

thenResponseShouldBeXML();
}

public void testGetPageHierarchyHasRightTags() throws Exception {
givenPages("PageOne", "PageOne.ChildOne", "PageTwo");

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

thenResponseShouldContain(
"<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"
);
}
  • given-when-then 관례 사용
    • 테스트 코드 일기가 쉬워짐
    • 불행히도 위에서 보듯이 테스트를 분리하면 중복된 코드가 많아짐
    • 해결? 템플릿 메서드 패턴 사용시 중복 제거 가능
  • 템플릿 메서드 패턴 : 중복 제거 가능
    • given/when을 부모 클래스에
    • then을 자식 클래스에
  • 다른 방법?
    • 독자적 클래스를 만들고 @Before에 given when을 넣고 @Test에 then부분을 넣어도 된다
  • 문제는? 모두가 배보다 배꼽이 크다
  • 모든걸 감안하면 결국 9-2처럼 assert을 여러 개를 사용하는 것이 좋다

테스트당 개념 하나

→테스트 함수마다 한 개념만 테스트하라

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 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());
}

바람직하지 못한 코드

  • 독자적인 개념 3개를 테스트 → 독자적 테스트 3개로 쪼개야
  • 한 함수일경우 독자가 각 절이 거기에 존재하는 이유와 각 절이 테스트하는 개념을 모두 이해해야 한다.

분리한 테스트 코드

  • (5월처럼) 31일로 끝나는 달의 마지막 날짜가 주어지는 경우
  1. (6월처럼) 30일로 끝나는 한 달을 더하면 날짜는 30일이 되어야지 31일이 되어서는 안 된다.
  2. 두 달을 더하면 그리고 두 번째 달이 31일로 끝나면 날짜는 31일이 되어야 한다.
  • (6월처럼) 30일로 끝나는 달의 마지막 날짜가 주어지는 경우
    1. 31일로 끝나는 한 달을 더하면 날짜는 30일이 되어야지 31일이 되면 안 된다.

분리하면? → 감춰진 일반적인 규칙이 보임

  • 날짜에 어떤 달을 더 하면 날짜는 그달의 마지막 날짜보다 커지지 못함
    → 2월+28일 + 1달 = 3월 28일 → 채우면 좋을 테스트 케이스

포인트

  • 개념당 assert 문을 최소로
  • 세트스 함수당 개념 하나만 테스트

F.I.R.S.T

  • 이미지 from

1. FAST

  • 테스트는 빨라야 한다
  • 느리면 자주 못 돌림 → 초반에 문제 못 찾음, 코드 마음껏 정리 불가 → 코드 품질 망가짐

2. Isolates

  • 각 테스트는 서로 의존하면 안된다
  • 각 테스트는 독립적으로 어떤 순서로 실행해도 괜찮아야 한다
  • 테스트가 의존하면? → 연쇄 실패 발생 → 원인 진단 어려움 → 테스트 결함 숨겨짐

3. Repeatable

  • 테스트는 어떤 환경에서도 반복 가능해야 한다
  • 실제 환경, QA 환경, 오프라인된 노트북에서도 실행 가능해야 한다
  • 테스트가 돌아가지 않는 환경이 하나라도 있으면 안된다

4. Self-validating

  • 테스트는 bool값으로 결과를 내야 한다
  • 스스로 성공과 실패가 가늠해야 한다
  • 그러지 않으면? → 주관적 판단 → 지루한 수작업 평가

4. Timely

  • 테스트는 적시에 작성해야 한다
  • 테스트 하려는 실제 코드 구현 직전에 구현한다
  • 실제 코드 구현 후엔? 실제 코드가 테스트 하기 어렵다는 사실 발견
  • 테스트가 불가능한 실제 코드 설계 했을 수도

결론

  • ‘깨끗한 테스트 코드’는 책 한 권을 할애해도 모자랄 주제다
  • 테스트 코드는 실제 코드의 유연성, 유지보수성, 재사용성을 보존/강화
    →실제 구현 코드보다 더 중요할지도
  • 테스트 코드는
    • 깨끗하게 지속적으로 관리
    • 표현력 높이고 간결하게 정리
    • 테스트 API를 구현해 DSL을 만들어 테스트 코드를 짜기 쉽게 만들자

Related POST

공유하기