Concept/클린코드
[클린코드] 단위테스트
devstep
2025. 5. 29. 23:45
TDD 법칙 세 가지
- 실패하는 단위 테스트 를 작성할 때까지 실제 코드를 작성하지 않는다.
- 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
- 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.
테스트 코드와 실제 코드가 함께 나올 뿐더러 테스트 코드가 실제 코드보다 불과 몇 초 전에 나온다.
이런 방식은 결과적으로 실제 코드를 거의 모두 테스트하게 되어, 테스트 코드의 관리가 어려워질 수 있다.
따라서 테스트 코드에도 실제 코드와 동일한 품질 기준을 적용하는 것이 중요하다.
이번 글에서는 유지보수가 쉬운, 클린한 테스트 코드를 작성하는 방법에 대해 살펴본다.
깨끗한 테스트 코드
깨끗한 테스트 코드를 만들려면 가독성이 필요하다!
1. 보조 메서드로 중복 제거: 세부구현을 없애고 의도 중심으로 추상화한다.
- 중복 제거
- 읽기 쉬움 : 테스트 코드는 최소의 표현으로 많은 것을 나타내야 한다.
- 의도가 명확한 테스트 : 의도를 표현하는 문서처럼 읽히는 것
테스트 코드 개선 예시
- makePages()는 내부적으로 PathParser.parse()를 감싼 유틸 메서드 테스트 의도를 더 명확히 표현함("이 페이지들을 만들겠다")
- responder, response, makeResponse() 호출 → “요청을 보낸다”는 submitRequest() 으로 추상화
- request 구성에 대한 디테일을 감추고→ 파라미터 "root", "type:pages"는 URL 구성 목적만 드러냄
//개선 전
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 testGetPageHierarchyAsXml() throws Exception {
makePages("PageOne", "PageOne.ChildOne", "PageTwo");
submitRequest("root", "type:pages");
assertResponseIsXML();
assertResponseContains(
"<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>");
}
2. BUILD-PERATE-CHECK 패턴
Given-When-Then : 개념은 거의 같고, 용어만 다를 뿐
- 테스트가 무엇을 하고 있는지 단계별로 명확하게 구분
의미 | 설명 | |
BUILD | 테스트 준비 (Setup) | 필요한 데이터, 객체, 환경을 생성하고 준비하는 단계 |
PERATE | 실행 (Operate) | 테스트 대상 메서드나 기능을 실제 호출하는 단계 |
CHECK | 검증 (Assert) | 실행 결과가 예상대로 나왔는지 검증하는 단계 |
3. 도메인에 특화된 테스트 언어 (DSL)
- 도메인에 특화된 테스트 언어(DSTL)의 핵심
- 테스트 코드에서 시스템의 API를 직접 다루는 대신, 그 위에 “테스트 전용 추상 계층”을 쌓자는 것.
- 테스트 코드 전용 특수 API 목적
- 테스트의 의도와 표현을 깔끔하게 분리하는 것.
- 예시
- makePages(), submitRequest() 유틸리티를 조합해 마치 자연어처럼 표현한 테스트 문장
- 이 방식은 테스트를 문서처럼, 명세처럼, 그리고 지속 가능한 자산으로 만들어 준다.
생각해보기 :
- [ ] 테스트 유틸 메서드를 만드는 것보다 테스트 유틸 메서드처럼 API를 만드는 것이 좋지 않을까? 단위 테스트의 범위를 작게 해서 해당 내용을 시스템의 메서드로 만들어도 되지 않을까? 즉, makePages(), submitRequest() 같은 메서드를 만들고 해당 메서드의 테스트 코드를 만드는 것은 어떨까?
- GPT 답변
"그 기능이 실제로 시스템에서 재사용되거나 의미 있는 비즈니스 동작이라면, 시스템 메서드로 만드는 것이 훨씬 낫습니다."
반대로, 그 기능이 테스트 목적에만 특화되어 있다면 테스트 유틸리티로 남겨두는 게 맞습니다. - [ ] 실제 코드에서 맞닥들여야 할 부분인 것 같다. 코드로 경험해보자.
4. 이중 표준
- 테스트 환경에서 돌아가는 코드이기 때문에 실제 코드만큼 효율적일 필요는 없다.
- 2가지 예시
- StringBuffer대신 String +=사용 : 효율보다 가독성을 중시
- 대문자는 on 켜짐을 나타내고, 소문자는 off 를 나타내는 등의 간결한 표현
1) 예시 코드
StringBuffer대신 String +=사용하기
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;
}
2) 예시 코드
대문자는 on 켜짐을 나타내고, 소문자는 off 를 나타내는 등의 간결한 표현
“그릇된 정보를 피하라” 라는 규칙에 위반되지만 빨리 판단 가능하다면 테스트코드에서는 허용.
// Bad
@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());
}
// Good : 간결한 표현
@Test
public void turnOnLoTempAlarmAtThreshold() throws Exception {
wayTooCold();
**assertEquals("HBchL", hw.getState());**
}
5. 테스트 당 assert 하나
- 예시
- “출력이 XML이다” assert
- “특정 문자열을 포함한다” assert
- 1개의 assert문을 주도록 강제할 경우에는 중복 코드가 많아질 위험이 있다.
- TEMPLATE METHOD 패턴을 사용하여 해결 할 수 있다.
- given/when 부분을 부모 클래스에 두고
- then 부분을 자식 클래스에 두는 방식으로
- 하지만 공통 부분을 별도의 method로 끄집어 내는데 들어가는 노력이 더 클 수 있습니다.
- TEMPLATE METHOD 패턴을 사용하여 해결 할 수 있다.
- 결국, 필요한 경우에는 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>"
);
}
6. 테스트 당 개념 하나
테스트 당 assert 하나 보다는 테스트 함수마다 한 개념만 테스트하는 규칙이 더 낫다.
- 개념당 assert문 수를 최소로 줄여라
- 테스트 함수 하나는 개념 하나만 테스트하라
/**
* addMonth() 메서드를 테스트하는 장황한 코드
*/
public void testAddMonths() {
SerialDate d1 = SerialDate.createInstance(31, 5, 2004);
// (6월처럼) 30일로 끝나는 한 달을 더하면 날짜는 30일이 되어야지 31일이 되어서는 안된다.
SerialDate d2 = SerialDate.addMonths(1, d1);
assertEquals(30, d2.getDayOfMonth());
assertEquals(6, d2.getMonth());
assertEquals(2004, d2.getYYYY());
// 두 달을 더하면 그리고 두 번째 달이 31일로 끝나면 날짜는 31일이 되어야 한다.
SerialDate d3 = SerialDate.addMonths(2, d1);
assertEquals(31, d3.getDayOfMonth());
assertEquals(7, d3.getMonth());
assertEquals(2004, d3.getYYYY());
// 31일로 끝나는 한 달을 더하면 날짜는 30일이 되어야지 31일이 되어서는 안된다.
SerialDate d4 = SerialDate.addMonths(1, SerialDate.addMonths(1, d1));
assertEquals(30, d4.getDayOfMonth());
assertEquals(7, d4.getMonth());
assertEquals(2004, d4.getYYYY());
}
F.I.R.S.T
깨끗한 테스트는 다음 다섯 가지 규칙을 따르는데, 각 규칙에서 첫 글자를 따오면 FIRST 가 된다.
- 빠르게Fast : 테스트는 빨라야 한다.
- 테스트는 빨리 돌아야 한다.
- 테스트가 느리면 자주 돌릴 엄두를 못낸다
- 자주 돌리지 않으면 초반에 문제를 찾아내 고치지 못한다.
- 코드를 마음껏 정리하지도 못한다.
- 결국 코드 품질이 망가지기 시작한다.
- 독립적으로Independent : 각 테스트는 서로 의존하면 안 된다.
- 한 테스트가 다음 테스트가 실행될 환경을 준비해서는 안된다.
- 각 테스트는 독립적으로 그리고 어떤 순서로 실행해도 괜찮아야 한다.
- 테스트가 서로에게 의존하면 하나가 실패할 때 나머지도 잇달아 실패하므로 원인을 진단하기 어려워지며 후반 테스트가 찾아내야 할 결함이 숨겨진다.
- 반복가능하게Repeatable : 테스트는 어떤 환경에서도 반복 가능해야 한다.
- 실제 환경, QA 환경, 노트북 환경에서도 실행할 수 있어야 한다.
- 테스트가 돌아가지 않는 환경이 하나라도 있다면 테스트가 실패한 이유를 둘러댈 변명이 생긴다.
- 환경이 지원되지 않기에 테스트를 수행하지 못하는 상황에 직면할 수 있다.
- 자가검증하는Self-Validating : 테스트는 Bool 값으로 결과를 내야 한다. 성공 아니면 실패다.
- 통과 여부를 알려고 로그 파일을 읽게 만들어서는 안된다.
- 통과 여부를 보려고 텍스트 파일 두 개를 수작업으로 비교하게 만들어서도 안 된다.
- 테스트가 스스로 성공과 실패를 가늠하지 않는다면 판단은 주관적이 되며 지루한 수작업 평가가 필요하게 된다.
- 적시에Timely : 테스트는 적시에 작성해야 한다.
- 단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현한다.
- 실제 코드를 구현한 다음에 테스트 코드를 만들면 실제 코드가 테스트하기 어렵다는 사실을 발견할지도 모른다.
- 어떤 실제 코드는 테스트하기 너무 어렵다고 판명날지도 모른다.
- 테스트가 불가능하도록 실제 코드를 설계할지도 모른다.
이번 챕터를 읽으며, 테스트 코드 역시 클린하게 작성해야 한다는 점을 알게 되었다.
물론 이전부터 중복된 코드는 메서드나 유틸리티 메서드로 분리해야 한다는 것은 알고 있었지만, 이제는 그 기준을 단순한 중복 제거가 아닌 가독성에 두어 테스트가 마치 문서처럼 의도를 잘 드러내도록 작성해야겠다는 생각이 들었다.
또한 테스트 코드도 실제 코드와는 다른 기준을 적용할 만큼 유지보수를 고려해 정성 들여 작성해야 한다는 사실을 새롭게 느꼈다.