넘치게 채우기

9장. 단위 테스트 본문

개발/Clean Code

9장. 단위 테스트

riveroverflow 2023. 8. 8. 19:23
728x90
반응형

 

1997년만 해도 TDD(Test Driven Development)라는 개념을 아무도 몰랐습니다.

우리 대다수에게 단위 테스트란, 자기의 프로그램이 ‘돌아간다’라는 사실만 확인하는 일회성 코드에 불과했습니다.

지금은 애자일과 TDD 덕분에 단위 테스트를 자동화하는 프로그래머들이 이미 많아졌으며, 점점 늘어나는 추세입니다.

 

TDD 법칙 세 가지

  • 첫째 법칙 : 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.
  • 둘째 법칙 : 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
  • 셋째 법칙 : 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.

 

위 세 가지 규칙을 따르면 개발과 테스트가 대략 30초 주기로 묶입니다.

테스트 코드와 실제 코드가 함께 나올뿐더러 테스트 코드가 실제 코드보다 불과 몇초 전에 나옵니다.

 

이렇게 일하면 매일 수십 개, 매달 수백 개, 매년 수천 개에 달하는 테스트 케이스가 나옵니다.

이렇게 일하면 실제 코드를 사실상 전부 테스트하는 케이스가 나옵니다.

하지만, 실제 코드와 맞먹을 정도로 방대한 테스트 코드는 심각한 관리 문제를 유발하기도 합니다.

 

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

테스트 코드가 지저분하면 실제 코드 변경시에 원활한 대응이 불가능합니다.

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

테스트 코드를 깨끗하게 짠다면 테스트에 쏟는 노력은 허사로 돌아가지 않습니다.

 

테스트 코드는 실제 코드 못지 않게 중요합니다. 테스트 코드는 사고와 설계와 주의가 필요합니다.

실제 코드 못지 않게 깨끗하게 짜야 합니다.

 

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

테스트 코드를 깨끗하게 유지하지 않으면 결국은 잃어버립니다.

테스트 케이스가 없다면 실제 코드를 유연하게 만드는 버팀목도 사라집니다.

 

코드에 유연성, 유지보수성, 재사용성을 제공하는 버팀목은 바로 단위 테스트입니다.

테스트 케이스가 있다면 변경이 두렵지 않아집니다. 테스트 케이스가 없다면 모든 변경은 잠재적인 버그가 됩니다.

 

실제 코드를 점검하는 자동화된 단위 테스트 슈트는 설계와 아키텍처를 최대한 깨끗하게 보존하는 열쇠입니다.

테스트는 유연성, 유지보수성, 재사용성을 재공합니다. 테스트가 있음으로 변경이 쉬워지기 때문입니다.

 

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

테스트 코드가 지저분할수록 실제 코드도 지저분해집니다.

 

깨끗한 테스트 코드

깨끗한 테스트 코드를 만들려면 필요한 세 가지가 있습니다.

  1. 가독성
  1. 가독성
  1. 가독성

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

테스트 코드는 최소의 표현으로 많은 것을 나타내야 합니다.

 

아래 예시는 FitNess의 코드입니다.

코드가 매우 읽기 불편할 것 입니다.

// SerializedPageResponderTest.java (9-1)
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);
}

아래는 개선된 코드입니다. 동일한 내용을 수행합니다.

// SerializedPageResponderTest.java (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패턴이 위와 같은 테스트 구조에 적합합니다.

  1. 첫 부분은 테스트 자료를 만듭니다.
  1. 두 번째 부분은 테스트 자료를 조작합니다.
  1. 세 번째 부분은 조작한 결과가 올바른지 확인합니다.

 

테스트 코드는 본론에 돌입해 진짜 필요한 자료 유형과 함수만 사용합니다. 그러므로 코드를 읽는 사람은 온갖 잡다하고 세세한 코드에 주눅들고 헷갈릴 필요 없이 코드가 수행할 기능을 재빨리 이해할 것 입니다.

 

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

위 개선된 코드는 도메인에 특화된 언어로, 테스트 코드를 구현하는 기법을 보여줍니다. 흔히 쓰는 시스템 조작 API를 사용하는 대신 API 위에다 함수와 유틸리티를 구현한 후 그 함수와 유틸리티를 사용하므로 테스트 코드를 짜기도 읽기도 쉬워집니다. 이렇게 구현한 함수와 유틸리티는 테스트 코드에서 사용되는 특수 API가 되는 것입니다.

 

테스트 언어는 테스트를 구현하는 당사자와 나중에 테스트를 읽어볼 독자 모두 돕습니다.

이런 테스트 API는 처음부터 설계된 것이 아닙니다. 잡다하고 세세한 사항으로 범벅된 코드를 계속 리팩터링하다 진화된 것 입니다.

숙련된 개발자라면 자기 코드를 좀 더 간결하고 표현력이 풍부한 코드로 리팩터링해야 마땅합니다.

 

이중 표준

테스트 코드에 적용하는 표준은 실제 코드에 적용하는 표준과 확실히 다릅니다.

단순, 간결, 표현력이 풍부해야 하지만, 실제 코드만큼 효율적일 필요는 없습니다.

아래는 온도가 급격하게 떨어지면 경보, 온풍기, 송풍기가 모두 가동되는지 확인하는 코드입니다.

// EnvironmentControllerTest.java (9-3)
@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 (9-4)
@Test
public void turnOnLoTempAlarmAtThreshold() throws Exception {
  wayTooCold();
  assertEquals("HBchL", hw.getState()); 
}

 

HBchL은 각 제어기의 켜짐/꺼짐을 보는 순서이고, 대문자는 켜짐을, 소문자는 꺼짐을 확인하라는 의미입니다.

이 방식이 그릇된 정보를 피하라(2장 의미 있는 이름 참조)규칙을 위반하지만,

이 상황에서는 적절할 수 있습니다.

 

아래의 테스트 코드들보다는 훨씬 가독성이 있음을 알 수 있을 겁니다.

// EnvironmentControllerTest.java (더 복잡한 선택) (9-5)
@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()); 
}

 

 

아래는 getState함수입니다. 코드가 그리 효율적이지 않습니다.

효율을 높이려면 StringBuffer가 더 적합하겠습니다.

// MockControlHardware.java (9-6)
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 하나

JUnit으로 테스트 코드를 짤 때는 함수마다 assert문을 단 하나만 사용해야 한다고 주장하는 학파가 있습니다.

가혹한 규칙일지도 모르지만, 9-5를 보면, 확실한 장점이 있습니다.

assert문이 하나면 결론이 하나라서 코드를 이해하기 쉽고 빠릅니다.

 

아래 코드처럼 테스트를 쪼개 각자가 assert를 수행하게 하면 됩니다.

9-2의 코드를 리팩터링 한 코드입니다.

// SerializedPageResponderTest.java (단일 Assert) (9-7)
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>"
  ); 
}

give-when-then이라는 관례를 사용한 것에 주목합시다. 그러면 테스트 코드를 읽기 쉬워집니다.

불행히도, 분리하면 이렇게 중복되는 코드가 많아집니다.

TEMPLATE METHOD 패턴을 사용하면 중복을 제거할 수 있습니다.

given-when부분은 부모 클래스에 두고, then 부분만 자식 클래스에 두면 됩니다.

물론 이러면 배보다 배꼽이 더 커지는 꼴이 됩니다.

 

결국은 9-2처럼 assert문을 여러 번 쓰는게 더 나을 수도 있다는 결론이 나옵니다.

엄격하게 assert문을 하나만 쓰라는 건 아니지만, assert문을 최소화 하는 것이 좋은 코드일 것 입니다.

 

테스트 당 개념 하나

어쩌면 ‘테스트 함수마다 한 개념만 테스트하라’는 규칙이 더 낫겠습니다.

아래는 바람직하지 못한 함수입니다. 새 개념을 한 함수로 몰아넣으면 독자가 각 절이 존재하는 이유와 각 절이 테스트하는 개념을 모두 알아야합니다.

// addMonth() 메서드를 테스트하는 장황한 코드 (9-8)
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문이 여러 개라는 문제보다는,

여러 개념을 테스트하기 때문에 문제입니다.

 

가장 좋은 규칙은

  1. assert문을 최소로 줄여라
  1. 테스트 함수 하나는 개념 하나만 테스트하라

가 되겠습니다.

 

F.I.R.S.T - 깨끗한 테스트의 규칙

깨끗한 테스트는 다음 다섯 가지 규칙을 따릅니다.

 

  • Fast(빠르게) :
    • 테스트는 빨라야 합니다. 빨리 돌아야 합니다.
    • 자주 돌리지 않으면 초반에 문제를 잡지 못합니다
    • 코드를 마음껏 정리하지도 못하게 됩니다.
    • 결국 코드 품질이 망가집니다.
  • Indenpendent(독립적으로) :
    • 각 테스트는 서로 의존하면 안 됩니다.
    • 한 테스트가 다음 테스트가 실행될 환경을 준비해서는 안 됩니다.
    • 테스트가 서로에게 의존하면 하나가 실패할 때 나머지도 잇따라 실패하므로 원인을 진단하기 어려워지며 후반 테스트가 찾아내야 할 결함이 숨겨집니다.
  • Repeatable(반복가능하게) :
    • 테스트는 어떤 환경에서도 반복 가능해야 합니다.(네트워크가 연결되지 않는 곳 환경, 실제 환경, QA 환경에서도)
    • 테스트가 돌아가지 않는 환경이 하나라도 있다면, 실패할 변명이 생깁니다.
  • Self-Validating(자가검증하는) :
    • 테스트는 부울값으로 결과를 내야 합니다.
    • 성공 아니면 실패.
    • 테스트가 스스로 성공과 실패를 가늠하지 않는다면 판단은 주간적이 되며, 지루한 수작업 평가가 필요하게 됩니다.
  • Timely(적시에) :
    • 테스트는 적시에 작성해야 합니다.
    • 단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현합니다.
    • 실제 코드를 구현한 뒤 테스트 코드를 만들면 실제 코드가 테스트하기 어렵다는 사실을 발견할지 모릅니다.
    • 테스트가 불가능하도록 실제 코드를 설계할지 모릅니다.

 

결론

깨끗한 테스트 코드는 사실 책 한 권을 내도 모자를 주제입니다.

어쩌면 실제 코드보다도 중요할지 모릅니다.

테스트 코드는 실제 코드의 유연성, 유지보수성, 재사용성을 보존하고 강화하기 때문입니다.

그러므로 우리는 테스트 코드륵 지속적으로 깨끗하게 관리해야 하고, 표현력을 높이고, 간결하게 정리해야 합니다.

테스트 API를 구현해 도메인 특화 언어를 만들면 그만큼 테스트 코드도 짜기 쉬워질 것입니다.

 

테스트 코드가 방치되어 망가지면 실제 코드도 망가진다. 테스트 코드를 깨끗하게 유지하자.

 

728x90
반응형

'개발 > Clean Code' 카테고리의 다른 글

10장. 클래스  (0) 2023.08.09
8장. 경계  (0) 2023.08.07
7장. 오류 처리  (0) 2023.08.05
6장. 객체와 자료 구조  (0) 2023.08.04
5장. 형식 맞추기  (0) 2023.08.03