Language/Java

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

비소_ 2022. 8. 16.

클릭하시면 네이버 Book으로 연결됩니다!

해당 서적을 참고하여 개인 공부용으로 정리한 글입니다.


Intro

  • 애자일과 TDD로 인해 단위 테스트를 자동화하는 프로그래머들이 많아졌다.
  • 더 이상 실행만 확인하는 일회성 코드가 아닌 제대로 된 테스트 코드를 작성해야 한다.

TDD 3 법칙

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

위 3가지 규칙을 통해 개발과 테스트가 대략 30초 주기로 묶인다.

빠르게 테스트 코드 및 테스트 케이스를 생산할 수 있다는 것이 장점이지만,

실제 코드와 맞먹는 양인 테스트 코드는 관리 문제를 유발하기도 한다.


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

지저분한 테스트 코드는 테스트를 하느니만 못하다.

또, 실제 코드 변경으로 인해 테스트 케이스가 실패하기 시작하면 지저분한 코드로 인해 더더욱 통과하기 어려워진다.

그렇다고 없애면 자신이 수정한 코드가 제대로 동작하는지 확인할 수가 없어,

수정해도 안전한 것인지 검증하지 못한다.

유연성, 유지보수성, 재사용성

단위 테스트는 코드에 유연성, 유지보수성, 재사용성을 제공한다.

테스트 케이스가 없다면 모든 변경이 잠정적인 버그다.

테스트 케이스가 있다면 변경이 쉬워지므로 개선에 용이하다.


깨끗한 테스트 코드

가독성은 실제 코드보다 테스트 코드에서 더욱 중요하다.

가독성을 높이려면 명료성, 단숭성, 풍부한 표현력, 즉, 최소의 표현으로 많은 것을 나타내야 한다.

아래 코드는 Fitness의 코드로 자잘한게 많아 표현력이 떨어진다.

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

아래는 이를 리팩터링한 코드다.

//목록 9-2 (아래에서 자주 언급됨)
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 패턴이 위와 같은 테스트 구조에 적합하다.

Given-When-Then과 비슷하다.

각 테스트는 세 부분으로 나뉜다.

  • 테스트 자료 생성
  • 테스트 자료 조작
  • 결과 확인

세세한 코드는 전부 치우고 필요한 자료 유형과 함수만을 사용함으로써 독자들이 코드가 수행하는 기능을 재빨리 이해할 수 있다.

도메인 특화 테스트 언어

목록 9-2는 DSL로 테스트 코드를 구현하는 기법을 보여준다.

시스템 조작 API 대신 API 위에다 함수와 유틸리티를 구현한 후 이를 이용하므로 테스트 코드를 짜기도 읽기도 쉬워진다.

이런 테스트 API는 처음부터 설계된 것이 아니고 리팩터링을 반복하며 진화된 API다.

이중 표준

테스트 코드는 가독성이 좋아야 하는 것은 맞지만 실제 코드만큼 효율적일 필요는 없다.

해당 코드는 저자가 환경 제어 시스템을 개발할 때 작성한 코드이다.

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

책에는 더 자세히 나와있지만 주목할 것은

일반적으로 여러 문자열을 concat할 때, Java는 StringBuffer나 StringBuilder를 사용하는 것이 효율적이다.

또한, 이 코드는 임베디드 시스템에 들어갈 것이기 때문에 효율을 위해서 그렇게 했어야 했지만,

테스트 환경은 자원이 제한적일 가능성이 낮으므로 가독성을 위해 연산자로 표현한 것이다.

이것이 '이중 표준'이다.


테스트 당 assert 하나?

테스트 코드 당 assert 문을 하나만 사용하라고 하는 사람이 있다.

결론이 하나이기 때문에 이해하기 쉽기 때문이다.

맞는 말이지만 항상 그럴까?

 

목록 9-2의 assert가 2개다.

하나는 출력이 XML인지 확인하고, 하나는 특정 문자열을 포함하는지 확인한다.

이 둘을 하나로 병합하기에는 불합리해보이므로, 테스트를 두 개로 쪼갠다.

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 부분을 자식 클래스에 둘 수도 있다.

또한, @Before 함수에 given/when 부분을 넣고 @Test 함수에 then을 넣어도 된다.

하지만 모두 배보다 배꼽이 더 크다.

 

때로는 주저 없이 함수 하나에 여러 assert 문을 사용하는 것도 필요하다.

다만 assert 문 개수는 최대한 줄여야 좋다.

테스트 당 개념 하나

테스트 함수마다 한 개념만 테스트하라는 규칙이 더 나을 수 있다.

한 함수에서 독자적인 개념 여러 개를 테스트하면 바람직하지 못하다.


F.I.R.S.T

깨끗한 테스트는 다음 5가지 규칙을 따른다.

  • Fast : 빨라야 한다. 테스트가 느리면 자주 돌릴 수 없다. 자주 돌리지 못하면 문제를 찾기도, 마음껏 정리도 못한다.
  • Independent : 각 테스트는 의존하면 안된다. 각 테스트는 독립적으로 그리고 어떤 순서로 실행해도 괜찮아야 한다.
  • Repeatable : 어떤 환경에서도 반복 가능해야 한다. 돌아가지 않는 환경이 있다면 실패한 이유를 둘러댈 변명이 생긴다. 또한, 해당 환경이 지원하지 않으므로 테스트를 수행하지 못하는 상황에 직면한다.
  • Self-Validating : 테스트는 bool 값으로 결과를 내야 한다. 테스트가 스스로 성공과 실패를 가늠하지 않는다면 판단은 주관적이 되며 지루한 수작업 평가가 필요하게 된다.
  • Timely : 테스트는 적시에 작성해야 한다. 실제 코드를 구현하기 직전에 구현한다. 반대로 하면 테스트가 어려워진다.

'Language > Java' 카테고리의 다른 글

[클린 코드] Ch.11 - 시스템  (0) 2022.08.19
[클린 코드] Ch.10 - 클래스  (0) 2022.08.18
[클린 코드] Ch.8 - 경계  (0) 2022.08.15
[클린 코드] Ch.7 - 오류 처리  (0) 2022.08.12
[클린 코드] Ch.6 - 객체와 자료 구조  (0) 2022.08.11

댓글