TDD 법칙 세 가지
세 가지 법칙
- 첫째 법칙: 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.
- 둘째 법칙: 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
- 셋째 법칙: 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.
나타나는 효과
- 위 세 가지 규칙을 따르면 개발과 테스트가 대략 30초 주기로 묶인다.
- 테스트 코드와 실제 코드가 함께 나올뿐더러 테스트 코드가 실제 코드보다 불과 몇 초 전에 나온다.
- 실제 코드를 사실상 전부 테스트하는 테스트 케이스가 나온다.
- 하지만 실제 코드와 맞먹을 정도로 방대한 테스트 코드는 심각한 관리 문제를 유발하기도 한다.
깨끗한 테스트 코드 유지하기
- 테스트는 유연성, 유지보수성, 재사용성을 제공한다.
- 테스트 코드가 지저분하면 코드를 변경하는 능력이 떨어지며 코드 구조를 개선하는 능력도 떨어진다.
- 테스트 코드가 지저분할수록 실제 코드도 지저분해진다.
- 테스트 코드는 실제 코드 못지 않게 중요하기 때문에 실제 코드 못지 않게 깨끗하게 짜야 한다.
- 테스트 케이스가 있으면 변경이 쉬워진다.
깨끗한 테스트 코드
- 깨끗한 테스트 코드를 만들려면 가독성이 중요하다.
- 테스트 코드는 최소의 표현으로 많은 것을 나타내야 한다.
SerializedPageResponderTest.java
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 호출에서 PathParser는 문자열을 pagePath 인스턴스로 변환한다. 이 코드는 테스트와 무관하며 테스트 코드의 의도만 흐린다.
- responder 객체를 생성하는 코드와 response를 수집해 변환하는 코드 역시 잡음에 불과하다.
- 게다가 resource와 인수에서 요청 URL을 만드는 어설픈 코드도 보인다.
- 마지막으로 위 코드는 읽는 사람을 고려하지 않는다.
SerializedPageResponderTest.java (리팩터링한 코드)
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가 된다. 즉, 테스트를 구현하는 당사자와 나중에 테스트를 읽어볼 독자를 도와주는 테스트 언어이다.
- 숙련된 개발자라면 자기 코드를 좀 더 간결하고 표현력이 풍부한 코드로 리팩터링해야 마땅하다.
이중 표준
- 테스트 API코드에 적용하는 표준은 실제 코드에 적용하는 표준과 확실히 다르다.
- 단순하고, 간결하고, 표현력이 풍부해야 하지만, 실제 코드만큼 효율적일 필요는 없다.
- 실제 환경이 아니라 테스트 환경에서 돌아가는 코드이기 때문인데, 실제 환경과 테스트 환경은 요구사항이 판이하게 다르다.
아래는 온도가 '급격하게 떨어지면' 경보, 온풍기, 송풍기가 모두 가동되는지 확인하는 코드이다.
EnvironmentControllerTest.java
@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());
}
위 코드는 세세한 사항이 아주 많다.
이런식으로 모든 state를 확인해야 하면 따분하고 미덥잖고 읽기가 어렵다.
EnvironmentControllerTest.java (리팩터링)
@Test public void turnOnLoTempAlarmAtThreshold() throws Exception { wayTooCold(); assertEquals("HBchL", hw.getState()); }
당연히 tic 함수는 wayTooCold라는 함수를 만들어 숨겼다.
assertEquals에 들어있는 이상한 문자열에 주목한다.
- 대문자는 '켜짐'이고 소문자는 '꺼짐'을 뜻한다.
- 문자는 항상 '{heater, blower, cooler, hi-temp-alarm, lo-temp-alarm}' 순서다.
- 위 방식이 그릇된 정보를 피하라는 규칙의 위반에 가깝지만 여기서는 적절해 보인다.
EnvironmentControllerTest.java (더 복잡한 선택)
@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());
}
자 이제 넘어가서 아래 새로운 코드를 보자.
코드가 그리 효율적이지 못하다는 사실에 주목한다. 효율을 높이려면 StringBuffer가 더 적합하다.
MockControlHardware.java
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 하나
assert가 하나라면 결론이 하나기 때문에 코드를 이해하기 빠르고 쉽다.
SerializedPageResponderTest.java (단일 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>"
);
}
- 위에서 보듯이 테스트를 분리하면 중복되는 코드가 많아진다.
- 아래와 같은 해결 방법이 있긴 하지만 모두가 배보다 배꼽이 더 크다. 이것저것 감안해 보면 결국 목록 9-2처럼 assert 문을 여럿 사용하는 편이 좋다고 생각한다.
- TEMPLATE METHOD 패턴을 사용하면 중복을 제거할 수 있다. given/when 부분을 부모 클래스에 두고 then 부분을 자식 클래스에 두면 된다.
- 완전히 독자적인 테스트 클래스를 만들어 @Before 함수에 given/when 부분을 넣고 @Test 함수에 then 부분을 넣어도 된다.
테스트당 개념 하나
어쩌면 "테스트 함수마다 한 개념만 테스트하라"는 규칙이 더 낫겠다.
이것저것 잡다한 개념을 연속으로 테스트하는 긴 함수는 피한다.
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일이 되면 안 된다.
- (5월처럼) 31일로 끝나는 달의 마지막 날짜가 주어지는 경우
개념들을 이렇게 정리해 표현하면 장황한 코드 속에 여러 개념을 테스트하고 있음을 알 수 있다.
이 경우 assert 문이 여럿이라는 사실이 문제가 아니라, 한 테스트 함수에서 여러 개념을 테스트한다는 사실이 문제다.
그러므로 가장 좋은 규칙은 "개념 당 assert 문 수를 최소로 줄여라"와 "테스트 함수 하나는 개념 하나만 테스트하라"라 하겠다.
F.I.R.S.T
빠르게 (Fast)
- 테스트는 빨라야 한다. 테스트는 빨리 돌아야 한다는 말이다.
- 테스트가 느리면 자주 돌릴 엄두를 못 낸다.
- 자주 돌리지 않으면 초반에 문제를 찾아내 고치지 못한다.
- 코드를 마음껏 정리하지도 못한다. 결국 코드 품질이 망가지기 시작한다.
독립적으로 (Independent)
- 각 테스트를 서로 의존하면 안 된다.
- 한 테스트가 다음 테스트가 실행될 환경을 준비해서는 안 된다.
- 각 테스트는 독립적으로 그리고 어떤 순서로 실행해도 괜찮아야 한다.
- 테스트가 서로에게 의존하면 하나가 실패할 때 나머지도 잇달아 실패하므로 원인을 진단하기 어려워지며 후반 테스트가 찾아내야 할 결함이 숨겨진다.
반복가능하게 (Repeatable)
- 테스트는 어떤 환경에서도 반복 가능해야 한다.
- 실제 환경, QA 환경, 네트워크가 연결되지 않은 환경에서도 실행할 수 있어야 한다.
- 테스트가 돌아가지 않는 환경이 하나라도 있다면 테스트가 실패한 이유를 둘러댈 변명이 생긴다.
- 게다가 환경이 지원되지 않기에 테스트를 수행하지 못하는 상황에 직면한다.
자가검증하는 (Self-Validating)
- 테스트는 bool 값으로 결과를 내야 한다.
- 성공 아니면 실패다.
- 통과 여부를 알리고 로그 파일을 읽게 만들어서는 안 된다.
- 통과 여부를 보려고 텍스트 파일 두 개를 수작업으로 비교하게 만들어서도 안 된다.
- 테스트가 스스로 성공과 실패를 가늠하지 않는다면 판단은 주관적이 되며 지루한 수작업 평가가 필요하게 된다.
적시에 (Timely)
- 테스트는 적시에 작성해야 한다.
- 단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현한다.
- 실제 코드를 구현한 다음에 테스트 코드를 만들면 실제 코드가 테스트하기 어렵다는 사실을 발견할지도 모른다.
- 어떤 실제 코드는 테스트하기 너무 어렵다고 판명날지 모른다.
- 테스트가 불가능하도록 실제 코드를 설계할지도 모른다.
'Clean Code' 카테고리의 다른 글
[Clean Code] 시스템 (0) | 2020.09.03 |
---|---|
[Clean Code] 클래스 (0) | 2020.09.03 |
[Clean Code] 경계 (0) | 2020.09.03 |
[Clean Code] 오류 처리 (0) | 2020.09.03 |
[Clean Code] 객체와 자료구조 (0) | 2020.09.03 |