[9장] 단위 테스트

애자일과 TDD(Test Driven Development) 덕분에 단위 테스트를 자동화하는 프로그래머들이 점점 많아졌으며 더 늘어나는 추세이며 이는 아주 눈부신 성장이다.하지만, 테스트를 추가하는데만 급급 하다보니 정작 좀 더 미묘하고 중요한 사실을 놓쳐버렸다.

이 장에서는 테스트 케이스를 잘 작성하는 방법에 대해 설명해주고 있다.


TDD 법칙 3가지

TDD가 실제 코드를 짜기 전에 단위 테스트부터 짜라고 요구한다는 사실은 누구나 알고 있지만, 그 규칙은 극히 일부에 불과하다.

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


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

이 책에서는 사례로 지저분해도 빨리 테스트 코드를 작성한 팀의 사례를 보여주며 깨끗한 테스트 코드를 유지하는 것의 중요성 을 보여준다. ‘지저분해도 빨리’ 작성한 테스트 코드는 결국 오히려 테스트를 안하느니만 못한 결과를 내놓는다. 테스트 코드는 유연성, 유지보수성, 재사용성 을 제공한다. 테스트 케이스가 있으면 잠정적인 버그 및 결함이 없을 것이라는 확신 하에 코드 변경이 쉬워진다. 따라서, 테스트 코드는 실제 코드 못지 않게 중요하다. 깨끗한 테스트 코드만이 성공한다.

깨끗한 테스트 코드

깨끗한 테스트 코드를 만드려면 어떻게 해야 하는가? -> 가독성이 중요하다. 가독성을 높히려면? -> 명료성, 단순성, 풍부한 표현력이 필요하다.

BAD : 리팩토링 전

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

전체적으로 독자를 고려하지 않은 코드이다. 테스트와 상관없는 코드들을 독자들이 이해해야 전체 코드를 이해할 수 있다. 테스트와 무관한 코드가 많다. 테스트 코드의 의도만 흐린다. resource와 인수에서 요청 URL을 만드는 어설픈 코드도 보인다.

GOOD : 리팩토링 후

// 리팩토링 후
// BUILD-OPERATE-CHECK 패턴
public void testSymbolicLinksAreNotInXmlPageHierarchy() throws Exception {
    // 1. 테스트 자료를 만든다.
	WikiPage page = makePage("PageOne");
	makePages("PageOne.ChildOne", "PageTwo");

    // 2. 테스트 자료를 조작한다.
	addLinkTo(page, "PageTwo", "SymPage");

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

    // 3. 조작한 결과가 올바른지 확인한다.
	assertResponseIsXML();
	assertResponseContains(
		"<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>");
	assertResponseDoesNotContain("SymPage");
}
  • BUILD-OPERATE-CHECK 패턴 사용(위와 같은 테스트 구조에 적합하다) - 테스트 자료를 만든다. - 테스트 자료를 조작한다. - 조작한 결과가 올바른지 확인한다. 잡다하고 세세한 코드들을 진짜 필요한 자료 유형과 함수로 묶어 처리했으므로, 코드를 읽는 독자는 테스트 코드가 수행하는 기능을 빠르게 정확하게 알아챌 수 있다.

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

위에서의 리팩토링 후 코드는 도메인에 특화된 언어(DSL(Domain Specified Language)) 로 테스트 코드를 구현하는 기법을 보여준다. API 위에다 함수와 유틸리티를 구현 한 후, 그 함수와 유틸리티를 사용하므로 테스트 코드를 짜기도 읽기도 쉬워진다. 이렇게 구현한 함수와 유틸리티는 테스트 코드에서 사용하는 특수한 API가 된다. 즉, 테스트 코드를 도와주는 테스트 특화 언어 가 되는 것이다.

이중 표준

테스트 API에 적용하는 표준은 실제 코드에 대한 표준과 당연히 다르다. 단순, 간결, 표현력이 풍부해야 하지만, 실제 코드만큼 효율적일 필요는 없다. 실제 환경이 아니라 테스트 환경에서 돌아가는 코드이고, 그 각각의 환경의 요구사항은 판이하게 다르다.

// 임베디드 시스템에서 사용하는 테스트 코드에 필요한 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;
}

효율을 높이려면 StringBuffer가 더 적합하다. 하지만, StringBuffer는 보기에 흉하다는 이유로 저자는 테스트 코드에 사용하지 않았다. 심지어, 이 코드를 사용하는 애플리케이션은 실시간 임베디드 시스템이다. 컴퓨터 자원과 메모리가 제한적일 가능성이 높다. 하지만, 테스트 환경은 자원이 제한적일 가능성이 낮다. 이것이 이중 표준의 본질이다. 실제 환경에서는 절대로 안되지만 테스트 환경에서는 전혀 문제 없는 방식이다.대개 메모리나 CPU 효율과 관련있는 경우다. 코드의 깨끗함과는 무관하다.


테스트 당 assert 하나

public void testGetPageHierarchyAsXml() throws Exception {
    //given
	givenPages("PageOne", "PageOne.ChildOne", "PageTwo");
    //when
	whenRequestIsIssued("root", "type:pages");
    //then
	thenResponseShouldBeXML();
}

public void testGetPageHierarchyHasRightTags() throws Exception {
	//given
    givenPages("PageOne", "PageOne.ChildOne", "PageTwo");
    //when
	whenRequestIsIssued("root", "type:pages");
    //then
	thenResponseShouldContain(
		"<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"
	);
}

JUnit으로 테스트 코드를 짤 때마다 메서드 하나당 assert문을 하나만 하자는 것이다. Given-When-Then 구조 를 사용한다. 가독성이 더욱 높아진다. 하지만, 그만큼 중복성이 높아진다. 사실 TEMPLATE METHOD 패턴(given/when을 부모 클래스, then을 자식 클래스) 이나 완전히 독자적인 테스트 클래스에 @before함수에 given/when을 @Test함수에 then부분을 넣을 수 있지만 모두가 배보다 배꼽이 더 큰 격이므로 차라리 assert문을 여러개 사용하는 편이 낫다. 따라서, 무조건적으로 테스트 하나당 assert문을 하나로 줄이는 것이 아닌 assert문 개수를 최대한 줄이는 것이 좋다.

해결책은 Test당 개념 하나

// AddMonths() 메서드를 테스트하는 장왕한 코드
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문이 여러개 있다는 사실이 문제가 아니다. 한 테스트에서 여러개의 개념을 한번에 테스트 하는 것이 문제이다. 가장 좋은 규칙은 개념 당 assert문 수를 최소한으로 줄여라, 테스트 함수 하나는 개념 하나만 테스트하라이다.


F.I.R.S.T.

Fast(빠르게) : 테스트는 빨리 돌아야 한다. 테스트가 느리면 자주 돌릴 엄두가 안난다. 자주 돌리지 못하면 초반에 문제를 찾아내지 못하게 되고, 곧 코드 품질이 망가지게 된다.

Independent(독립적으로) : 각 테스트는 서로 의존하면 안된다. 한 테스트가 다음 테스트가 실행될 환경을 준비해서는 안된다. 각 테스트는 독립적으로 어떤 순서로 실행해도 괜찮아야 한다.

Repeatable(반복가능하게) : 테스트는 어떤 환경에서도 반복 가능해야 한다. 실제 환경, QA 환경, 버스를 타고 집에 가는 길에 사용하는 노트북 환경(네트워크에 연결되지 않은)에서도 실행할 수 있어야 한다.

Self-Validating(자가 검증하는) : 테스트는 부울(Boolean) 값으로 결과를 내야 한다. 성공 아니면 실패다.

Timely(적시에) : 테스트는 적시에 작성해야 한다. 단위 테스트는 실제 코드를 구현하기 직전에 구현해야 한다. 실제 코드를 구현한 다음에 테스트 코드를 만들면 실제 코드가 테스트하기 어렵다는 사실을 발견할지도 모른다.


결론

깨끗한 테스트 코드는 실제 코드만큼이나 오히려 실제 코드보다 더 중요할 수 있다. 실제 코드의 유연성, 재사용성, 유지보수성을 보존하고 강화하기 때문이다. 테스트 API 를 구현해 도메인 특화 언어(DSL) 를 만들자. 테스트 코드가 방치되어 망가지면 실제 코드도 망가진다.테스트 코드를 깨끗하게 유지하자



Reference

  • 클린코드 , 로버트C. 마틴

Leave a comment