📕 목차
1. TDD 법칙 세 가지
2. 깨끗한 테스트 코드 유지하기
3. 깨끗한 테스트 코드
4. 테스트 당 assert 하나
5. F.I.R.S.T
1. TDD 법칙 세 가지
- 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.
- 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
- 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.
공감이 안 간다면 TDD 개발론에 대해 공부해보면 된다.
정말 재밌고 흥미로운 프로그래밍 관점을 터득할 수 있게 된다. :)
다만 이렇게 하면 실제 코드를 사실상 전부 테스트하는 테스트 케이스가 나온다.
방대한 양의 테스트 코드는 심각한 관리 문제를 유발하기도 한다.
2. 깨끗한 테스트 코드 유지하기
💡 테스트 코드는 실제 코드 못지 않게 중요하다
- 테스트를 안 하는 것보다 지저분한 테스트 코드라도 있는 편이 좋다는 착각을 버려라. 오히려 안 하는 것보다 못하다.
- 테스트 코드는 실제 코드와 함께 진화해야 한다.
- 테스트 코드가 복잡하면 실제 코드를 짜는 시간보다 테스트 케이스를 추가하는 시간이 더 걸린다.
- 비대해진 테스트 코드를 폐기하면 개발자는 안정성 검증을 수행하지 못해 변경을 주저하게 된다. (코드가 망가지는 시점)
📌 테스트는 유연성, 유지보수성, 재사용성을 제공한다.
- 단위테스트는 변경에 대한 두려움을 낮추어, 실제 코드의 유연성의 버팀목이 된다.
- 테스트 케이스가 없으면 버그가 숨어들까 두려워 변경을 주저하게 된다.
- 테스트 커버리지가 높을수록 공포는 줄어들고, 안심하고 아니텍처와 설계를 개선할 수 있다.
- 실제 코드를 점검하는 자동화된 단위 테스트 슈트는 설꼐와 아키텍처를 최대한 깨끗하게 보존하는 열쇠가 된다.
- 테스트 코드가 지저분할 수록 실제 코드는 망가지게 되고, 테스트 코드를 잃어버리게 된다.
3. 깨끗한 테스트 코드
💡 깨끗한 테스트 코드를 위해서는 가독성, 가동성, 그리고 가독성이 필요하다.
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);
}
- addPage와 assertSubString을 부르느라 중복 코드가 너무 많다.
- 자질구한 사항이 너무 많아 표현력이 떨어진다.
- PathParser가 반환하는 pagePath 인스턴스는 crawler가 사용하는 객체이므로 테스트 케이스와 무관하다.
- responder 객체를 생성하는 코드와 response를 수집해 변환하는 코드 역시 잡음에 불과하다.
- resource와 인수에서 요청 URL을 만드는 어설픈 코드도 있다.
- 읽는 사람을 고려하지 않는다.
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 패턴이 위와 같은 테스트 구조에 적합하다.
- 테스트 자료를 만든다
- 테스트 자료를 조작한다
- 조작한 결과가 올바른지 확인한다.
- 테스트 코드는 본론에 돌입해 진짜 필요한 자료 유형과 함수만을 남기고 모두 없애라.
✒️ Build - Operate - Check 패턴의 또 다른 예
public class ContactDAOTest{
@Test
public void testCreateContactData() throws Exception {
// Build
Contact contact = buildContactData("foo", "seoul");
// Operate
String id = new ContactDAO().create(contact);
// check the id and make sure that its not null/blank
assertNotNull(id);
}
private Contact buildContactData(String name, String city) {
Contact contact = new Contact();
contact.setName(name);
contact.setCity(city);
return contact;
}
}
📌 도메인에 특화된 테스트 언어(DSL)
- 시스템 조작 API보다 API 위에 함수와 유틸리티를 구현한 후, 그 함수와 유틸리티를 사용하므로 테스트 코드를 짜기도 읽기도 쉬워진다.
- 이렇게 구현한 함수와 유틸리티는 테스트 코드에서 사용하는 특수 API가 된다.
- 즉, 구현하는 당사자와 리뷰어를 도와주는 테스트 언어다.
- 숙력된 개발자라면 자기 코드를 좀 더 간결하고 표현력이 풍부한 코드로 리팩터링해야 마땅하다.
📌 이중 표준
💡 테스트 코드에 적용하는 표준은 실제 코드에 적용하는 표준과 달리 효율적일 필요는 없다.
- 테스트 환경은 컴퓨터 자원과 메모리가 제한적일 가능성이 낮다.
- 코드의 깨끗함과는 철저히 무관하다.
1️⃣ 보기 지루한 Test Case
@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());
}
- 세세한 사항이 너무 많다.
- 점검하는 상태 이름과 상태 값을 확인하느라 눈길이 너무 흩어진다.
2️⃣ 리팩터링
@Test
public void turnOnLoTempAlarmAtThreshold() throws Exception {
wayTooCold();
assertEquals("HBchl", hw.getState());
}
- tic 함수는 wayTooCold 함수를 만들어 숨겼다.
- "HBchl"은 "{heater, blower, cooler, hi-temp-alarm, lo-temp-alarm}" 순서를 의미한다. (대문자는 켜짐, 소문자는 꺼짐)
- 변수명의 "그릇된 정보를 피하라"는 규칙의 위반에 가깝지만, 테스트 코드 환경에서는 적절해보인다.
3️⃣ 모든 경우에 대해 추가
@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());
}
- 코드가 그리 효율적이지 못하다.
4️⃣ 효율성을 따져본 결과
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를 안 써서 치르는 대가가 미미하기 때문이다.
4. 테스트 당 assert 하나
JUnit으로 테스트 코드를 짤 때는 함수마다 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 관례를 사용했다.
- assert문이 단 하나이므로 함수의 결론도 하나여서 이해가 쉽고 빠르다.
- 중복되는 코드가 많아진다.
- 중복 코드를 제거하기 위해 Template Method 패턴을 사용할 수 있다.
- given/when 부분을 부모 클래스에 두고 then 부분을 자식 클래스에 두는 방법
- @Before 함수에 given/when 부분을 넣고 @Test 함수에 then 부분을 넣는 부분
- 뭐가 됐건 배보다 배꼽이 커진다.
- 중복 코드를 제거하기 위해 Template Method 패턴을 사용할 수 있다.
'단일 assert 문'은 훌륭한 규칙이긴 하지만 떄로는 주저 없이 하나의 함수 하나에 여러 assert 문을 넣기도 한다.
💡 단지 assert 문 개수는 최대한 줄여야 좋다.
더보기
✒️ Template Method 패턴을 적용했다면
// AbstractTest 클래스 - 상위 추상 클래스
public abstract class AbstractTest {
protected abstract void givenPages(String... pages);
protected abstract void whenRequestIsIssued(String root, String type);
protected abstract void thenResponseShouldBeXML();
protected void thenResponseShouldContain(String... expectedTags) {
// 공통적으로 사용되는 테스트 메서드의 구현
// expectedTags를 사용하여 특정 XML 태그가 응답에 포함되어 있는지 확인하는 로직 등을 구현할 수 있음
}
}
// 테스트 클래스
public class PageHierarchyTest extends AbstractTest {
private String[] pages;
@Before
public void setUp() {
givenPages("PageOne", "PageOne.ChildOne", "PageTwo");
whenRequestIsIssued("root", "type:pages");
}
@Override
protected void givenPages(String... pages) {
this.pages = pages;
// 페이지 설정 로직
}
@Override
protected void whenRequestIsIssued(String root, String type) {
// 요청 로직
}
@Override
protected void thenResponseShouldBeXML() {
// XML 응답 검증 로직
}
@Test
public void testGetPageHierarchyAsXml() throws Exception {
thenResponseShouldBeXML();
}
@Test
public void testGetPageHierarchyHasRightTags() throws Exception {
thenResponseShouldContain(
"<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"
);
}
}
📌 테스트 당 개념 하나
💡테스트 함수마다 한 개념만 테스트하라
/**
* 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 문이 여럿인 게 아니라, 여러 개념을 하나의 함수에서 테스트한다는 사실이다.
5. F.I.R.S.T
📌 빠르게 (Fast)
- 테스트는 빨라야 한다.
- 테스트가 느리면 자주 돌릴 엄두를 못낸다.
- 자주 돌리지 않으면 초반에 문제를 찾아내 고치지 못한다.
📌 독립적으로 (Independent)
- 각 테스트는 서로 의존하면 안 된다.
- 한 테스트가 다음 테스트가 실행될 환경을 준비해서는 안 된다.
- 각 테스트는 독립적으로 어떤 순서로 실행해도 괜찮아야 한다.
- 테스트가 의존하면 하나가 실패하면 잇달아 실패한다.
- 원인을 진단하기 힘들어지고 후반 테스트가 찾아내야 할 결함시 숨겨진다
📌 반복가능하게 (Repeatable)
- 어떤 환경에서도 반복 가능해야 한다.
- 실제 환경, QA 환경, 네트워크가 연결되어 있지 않은 노트북에서도 실행되어야 한다.
📌 자가검증하는 (Self-Validating)
- 테스트는 bool값으로 결과를 내야 한다. (성공 아니면 실패만이 존재한다.)
- 통과 여부를 알려고 로그 파일을 읽게 만들면 안 된다.
- 통과 여부를 보려고 텍스트 파일 두 개를 수작업으로 비교하게 만들어서도 안 된다.
- 테스트는 스스로 성공과 실패를 가늠해야 한다.
📌 적시에 (Timely)
- 단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현한다.
- 실제 코드를 구현한 다음에 만들면 실제 코드가 테스트하기 어렵다는 사실을 발견할 수도 있다.
- 어떤 실제 코드는 테스트하기 너무 어렵다고 판명날지 모른다.
- 테스트가 불가능하도록 실제 코드를 설계할지도 모른다.