클린코드(CleanCode) 독후감

[클린코드(CleanCode)] 9장 단위 테스트

BlackWolfDev 2025. 1. 15. 14:52

용어 설명

테스트 주도 개발(Test-driven development TDD)은 매우 짧은 개발 사이클을 반복하는 소프트웨어 개발 프로세스 중 하나이다. 개발자는 먼저 요구사항을 검증하는 자동화된 테스트 케이스를 작성한다. 그런 후에, 그 테스트 케이스를 통과하기 위한 최소한의 코드를 생성한다. 마지막으로 작성한 코드를 표준에 맞도록 리펙토링한다.
- Wikipedia
유닛 테스트(unit test)는 컴퓨터 프로그래밍에서 소스 코드의 특정 모듈이 의도된 대로 정확히 작동하는지 검증하는 절차다. 즉, 모든 함수와 메소드에 대한 테스트 케이스(Test case)를 작성하는 절차를 말한다. 이를 통해서 언제라도 코드 변경으로 인해 문제가 발생할 경우, 단시간 내에 이를 파악하고 바로 잡을 수 있도록 해준다.
- Wikipedia

개요

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

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

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


하지만 우리 분야에 테스트를 추가하려고 급하게 서두르는 와중에 많은 프로그래머들이 제대로 된 테스트 케이스를 작성해야 한다는 좀 더 미묘한(그리고 더욱 중요한) 사실을 놓쳐버렸다.


TDD 법칙 세가지

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

위 세 가지 규칙을 따르면 개발과 테스트가 대략 30초 주기로 묶이며, 테스트 코드가 실제 코드보다 먼저 작성된다.

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

실제 코드를 사실상 전부 테스트하는 테스트 케이스가 나온다. 

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


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

테스트 코드 품질에 관한 한 팀의 실패 사례와 그 교훈을 소개한다.

몇 년 전 한 팀이 테스트 코드에 실제 코드와 동일한 품질 기준을 적용하지 않기로 결정했다.

"지저분해도 빨리"라는 모토 아래, 변수 이름이나 함수의 가독성, 코드 설계를 무시하고 단순히 동작만 확인하는 방식을 채택했다.

 

하지만 이는 심각한 문제를 초래했다.

실제 코드가 진화하면서 테스트 코드도 함께 변경해야 했는데, 지저분한 테스트 코드는 수정이 점점 더 어려워졌다.

테스트 케이스를 추가하거나 수정하는 데 실제 코드를 작성하는 것보다 더 많은 시간이 소요되었고, 유지보수 비용도 계속 증가했다.

 

이러한 상황은 악순환을 만들어냈다.

테스트 코드는 팀의 가장 큰 불만이 되었고, 결국 테스트 슈트를 폐기해야 하는 상황에 이르렀다.

하지만 테스트 슈트가 없어지자 코드 검증이 불가능해졌고, 결함률이 증가했다.

개발자들은 코드 수정을 꺼리게 되었고, 이는 전반적인 코드 품질 저하로 이어졌다.

 

결과적으로 이 팀은 테스트 슈트도 없고, 뒤섞인 코드, 좌절한 고객만 남게 되었다.

테스트에 들인 노력은 허사가 되었지만, 실패의 진짜 원인은 테스트 코드를 막 짜도 된다고 허용한 초기 결정에 있었다.

많은 성공적인 팀들의 사례가 보여주듯, 테스트 코드는 실제 코드만큼 중요하며 깨끗하게 작성되어야 한다.

테스트 코드는 이류 시민이 아니라 실제 코드와 동등한 수준의 사고와 설계, 주의가 필요한 것이다.

 

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

깨끗한 테스트 코드가 프로젝트의 성패를 가른다.

테스트 케이스는 코드의 유연성, 유지 보수성, 재사용성을 보장하는 핵심 버팀목이기 때문이다.

 

테스트 케이스가 있으면 개발자는 변경을 두려워하지 않는다.

테스트 커버리지가 높을수록 버그에 대한 걱정도 줄어들어 코드를 자유롭게 개선할 수 있다.

반면 테스트 코드가 지저분하면 변경이 어려워지고, 이는 실제 코드의 품질 저하로 이어진다.

결국 테스트 코드를 잃으면서 실제 코드도 함께 망가지게 된다.


깨끗한 테스트 코드

깨끗한 테스트 코드의 핵심은 가독성이며, 이는 실제 코드보다도 더 중요할 수 있다.

가독성 높은 테스트 코드를 만들기 위해서는 다음 세 가지 요소가 필요하다:

  • 명료성
  • 단순성
  • 풍부한 표현력

테스트 코드는 최소한의 코드로 의도를 명확하게 전달해야 한다.

 

예를 들어 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);
}

PathParser를 예시로 설명하자면 기존 코드의 문제점과 이를 개선해야 하는 이유는 다음과 같다:

  1. 불필요한 코드가 테스트의 의도를 가린다:
    • PathParser가 문자열을 pagePath 인스턴스로 변환하는 과정
    • responder 객체 생성 코드
    • response 수집 및 변환 코드
    • URL 생성을 위한 resource와 인수 처리 코드
  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 패턴이 위와 같은 테스트 구조에 적합하다. 

각 테스트는 명확히 세 부분으로 나눠진다. 

  • 첫 부분은 테스트 자료를 만든다. 
  • 두 번째 부분은 테스트 자료를 조작하며, 
  • 세 번째 부분은 조작한 결과가 올바른지 확인한다.

잡다하고 세세한 코드를 거의 다 없앴다는 사실에 주목한다.

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

그러므로 코드를 읽는 사람은 온갖 잡다하고 세세한 코드에 주눅 들고 헷갈릴 필요 없이 코드가 수행하는 기능을 재빨리 이해한다.

 

도메인에 특화된 언어

위에서 개선한 코드는 도메인 특화 언어(DSL)를 활용한 테스트 코드 구현 방식을 보여준다.

일반적인 시스템 API 대신, 그 위에 사용하기 쉬운 함수와 유틸리티를 구현하여 테스트 코드의 가독성과 작성 용이성을 높인다.

 

이러한 테스트 API는 처음부터 완벽하게 설계되지 않는다.

대신 복잡하고 세부적인 코드를 지속적으로 리팩토링하는 과정에서 자연스럽게 발전한다.

숙련된 개발자라면 개선한 코드처럼 더 간결하고 표현력 있게 리팩토링해야 한다.

 

핵심은 테스트 코드만의 특수한 API를 만들어 테스트 작성자와 독자 모두를 돕는 테스트 언어로 발전시키는 것이다.

 

이중 표준

테스트 코드에 적용되는 표준은 실제 코드의 표준과는 다른 특성을 가진다. 테스트 코드는:

  1. 갖춰야 할 특성:
    • 단순성
    • 간결성
    • 풍부한 표현력
  2. 차이점:
    • 실제 코드만큼 효율적일 필요는 없음
    • 테스트 환경과 실제 환경의 요구사항이 다르기 때문

이러한 내용은 앞서 언급된 팀이 내린 판단이 일부 측면에서 옳았다는 것을 보여주며, 아래의 온도 경보 시스템 테스트는 이러한 특성을 보여주는 예시이다.

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

물론 위 코드는 세세한 사항이 아주 많다. 

예를 들어, tic함수가 무엇인지 지금은 신경쓰지 말자. 

단지 시스템 최종 상태의 온도가 "급강하"했는지 그것만 신경 써서 살펴보기 바란다.

위의 코드를 읽으면 점검하는 상태 이름과 상태 값을 확인하느라 눈길이 이리저리 흩어진다.

heaterState라는 상태 이름을 확인하고 왼쪽으로 눈길을 돌려 assertTrue를 읽는다.

이런식으로 모든 state를 확인해야 하면 읽기가 어렵다

그래서 아래와 같이 변환해 코드 가독성을 크게 높였다.

@Test
public void turnOnLoTempAlarmAtThreshold() throws Exception {
  wayTooCold();
  assertEquals("HBchL", hw.getState()); 
}

당연히 tic 함수는 wayTooCold라는 함수를 만들어 숨겼다. 

그런데 assertEquals에 들어있는 이상한 문자열에 주목한다. 

대문자는 '켜짐'이고 소문자는 '꺼짐'을 뜻한다. 문자는 항상 '{heater, blower, cooler, hi-temp-alarm, lo-temp-alarm}' 순서다.

비롯 위 방식이 그릇된 정보를 피하라는 규칙의 위반에 가깝지만 여기서는 적절해 보인다. 

일단 의미만 안다면 눈길이 문자열을 따라 움직이며 결과를 재빨리 판단한다. 

테스트 코드를 읽기가 사뭇 즐거워진다. 

아래 코드를 살펴보면 테스트 코드를 이해하기 너무도 쉽다는 사실이 분명히 드러난다.

@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가 더 적합하다.

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를 하나만 사용해야 한다고 주장하는 의견이 있다.

이는 엄격해 보일 수 있지만, assert가 하나일 때 테스트의 결론이 명확해져 코드를 이해하기 쉽고 빠르다는 장점이 있다.

 

하지만 위에서 언급한 코드에서 "출력이 XML이다"와 "특정 문자열을 포함한다"라는 assert를 하나로 합치는 것이 불합리한 경우도 있다.

이런 경우에는 아래처럼 테스트를 여러 개로 나눠 각각이 하나의 assert를 수행하도록 하는 것이 좋은 해결책이 될 수 있다.

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 관례를 사용하여 테스트 코드의 가독성을 높일 수 있지만, 이는 테스트를 분리할 때 코드 중복을 초래할 수 있다.

이러한 중복을 해결하기 위한 방법들이 있다:

  • TEMPLATE METHOD 패턴: given/when을 부모 클래스에, then을 자식 클래스에 두는 방식
  • 독립적인 테스트 클래스: @Before에 given/when을, @Test에 then을 두는 방식

하지만 이러한 해결책들은 오히려 더 복잡한 문제를 만들 수 있다. 그래서 결국 목록 9-2처럼 여러 assert 문을 사용하는 것이 더 실용적일 수 있다.

단일 assert 문 규칙은 좋은 지침이며, 가능한 한 이를 따르려 노력해야 한다. 하지만 상황에 따라 유연하게 적용할 필요가 있으며, 중요한 것은 assert 문의 개수를 최소화하는 것이다.

테스트당 개념 하나

"테스트 함수마다 한 개념만 테스트하라"는 규칙이 더 실용적일 수 있다.

 

하나의 테스트 함수에 여러 개념을 한꺼번에 테스트하면 코드를 읽는 사람이 각 부분의 존재 이유와 테스트하는 개념을 모두 파악해야 하는 부담이 생긴다.

따라서 아래와 같이 세 가지 독립적인 개념을 테스트하는 긴 함수는 각각의 개념을 테스트하는 세 개의 독립된 테스트로 분리하는 것이 바람직하다.

/**
 * 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());
}

셋으로 분리한 테스트 함수는 각각 다음 기능을 수행한다.

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

개념들을 이렇게 정리해 표현하면 장황한 코드 속에 여러 개념을 테스트하고 있음을 알 수 있다.

 이 경우 assert 문이 여럿이라는 사실이 문제가 아니라, 한 테스트 함수에서 여러 개념을 테스트한다는 사실이 문제다. 

그러므로 가장 좋은 규칙은 "개념 당 assert 문 수를 최소로 줄여라"와 "테스트 함수 하나는 개념 하나만 테스트하라"라 하겠다.


F.I.R.S.T

깨끗한 테스트는 다음 다섯 가지 규칙을 따르는데, 각 규칙에서 첫 글자를 따오면 FIRST가 된다.

빠르게Fast:
테스트는 빨라야 한다.

테스트가 느리면 자주 돌릴 엄두를 못 낸다.

자주 돌리지 않으면 초반에 문제를 찾아내 고치지 못한다.

코드를 마음껏 정리하지도 못한다.

결국 코드 품질이 망가지기 시작한다.

독립적으로Independent:
각 테스트를 서로 의존하면 안 된다.

한 테스트가 다음 테스트가 실행될 환경을 준비해서는 안 된다.

각 테스트는 독립적으로 그리고 어떤 순서로 실행해도 괜찮아야 한다.

테스트가 서로에게 의존하면 하나가 실패할 때 나머지도 잇달아 실패하므로 원인을 진단하기 어려워지며 후반 테스트가 찾아내야 할 결함이 숨겨진다.

반복가능하게Repeatable:
테스트는 어떤 환경에서도 반복 가능해야 한다.

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

테스트가 돌아가지 않는 환경이 하나라도 있다면 테스트가 실패한 이유를 둘러댈 변명이 생긴다.

게다가 환경이 지원되지 않기에 테스트를 수행하지 못하는 상황에 직면한다.

자가검증하는Self-Validating:
테스트는 bool 값으로 결과를 내야 한다.

성공 아니면 실패다.

통과 여부를 알리고 로그 파일을 읽게 만들어서는 안 된다.

통과 여부를 보려고 텍스트 파일 두 개를 수작업으로 비교하게 만들어서도 안 된다.

테스트가 스스로 성공과 실패를 가늠하지 않는다면 판단은 주관적이 되며 지루한 수작업 평가가 필요하게 된다.

적시에Timely:
테스트는 적시에 작성해야 한다.

단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현한다.

실제 코드를 구현한 다음에 테스트 코드를 만들면 실제 코드가 테스트하기 어렵다는 사실을 발견할지도 모른다.

어떤 실제 코드는 테스트하기 너무 어렵다고 판명 날지 모른다.

테스트가 불가능하도록 실제 코드를 설계할지도 모른다.


결론

"깨끗한 테스트 코드"는 한 권의 책으로도 다루기 어려울 만큼 중요한 주제이다.

테스트 코드는 실제 코드의 건강성을 좌우하며, 때로는 실제 코드보다 더 중요할 수 있다.

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

따라서 테스트 코드는:

  • 지속적으로 깨끗하게 관리되어야 함
  • 표현력을 높이고 간결하게 정리해야 함
  • 테스트 API를 통해 도메인 특화 언어(DSL)를 구현해야 함

이러한 관리가 제대로 이루어지지 않으면 테스트 코드가 망가지고, 결과적으로 실제 코드도 망가지게 된다.

그러므로 테스트 코드의 깨끗함을 유지하는 것이 매우 중요하다.


글쓴이의 생각

대학교 과제할 때는 요구사항에 따라 코드를 작성하고 제출하여 조교님들이 만든 테스트 코드를 돌렸을 때 문제없이 돌아가면 점수를 얻는 방식이었다.

과제를 하느라 시간이 없어 내가 따로 테스트 코드를 만들어보진 못했고 그냥 값만 입력하여 잘 돌아가면 제출했던 기억이 난다.

지금까지 일하면서 대학생 때랑 똑같이 반복 중인데 회사 프로젝트가 점점 커지니까 슬슬 코드를 돌려볼 테스트를 만들어 보는 시도를 해봐야겠다. 

728x90
반응형