단위 테스트
- 단위 테스트는 모듈(클래스) 단위로 정상적으로 작동하는지 모든 메소드에 대해 정상적으로 작동하는지 테스트하는것이다.
- 하지만 많은 프로그래머들이 제대로 된 테스트 케이스를 작성해야한다는 중요한 사실을 놓쳐버린다.
TDD 법칙 세 가지
- 첫 번째 법칙: 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.
- 두 번째 법칙: 컴파일을 실행하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
- 세 번째 법칙: 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.
깨끗한 테스트 코드 유지하기
- 테스트 코드를 작성한다고 작성하지 않을때 보다 무조건 좋은것은 아니다.
- 테스트 코드를 깨끗하게 짜는 것이 중요하다.
- 깨끗하게 코드를 짜지 않는다면 새 버전을 출시 할 때마다 팀이 테스트 케이스를 유지하고 보수하는 비용이 늘어난다.
- 결함율이 높아지고, 이로인해 변경하면 득보다 해가 더 크다고 생각해 코드를 정리하지 않게 될 수도있다.
- 또한 테스트에 쏟은 시간또한 허사로 돌아간다.
- 그렇기 때문에, 테스트 코드는 실제 코드 못지 않게 중요하다.
테스트는 유연성, 유지보수성, 재사용성을 제공한다.
- 테스트 코드를 깨끗하게 유지하지 않으면 결국 폐기할 수 밖에 없는 코드가 된다.
- 그렇다고 테스트 코드가 없다면 실제 코드를 유연하게 만드는 버팀목이 사라지고, 그로 인해서 개발자는 버그가 두려워 변경을 주저한다.
- 하지만 테스트 케이스가 있다면 공포는 사라지게 된다.
- 실제 코드를 점검하는 자동화된 단위 테스트는 설계와 아키텍처를 최대한 깨끗하게 보존하는 열쇠다.
- 테스트 케이스가 있으면 변경이 쉬워지기 때문이다.
- 따라서 테스트 코드가 지저분해지면 코드를 변경하는 능력이 떨어지며 코드 구조를 개선하는 능력도 떨어진다.
- 테스트 코드가 지저분할수록 실제 코드도 지저분해진다.
- 결국 테스트 코드를 잃어버리고 실제 코드도 망가진다.
깨끗한 테스트 코드
- 테스트 코드를 작성함에 있어서 가장 중요한 부분은 가독성이다.
- 그러기 위해서는 명료성, 단순성, 풍부한 표현력이 필요하다.
- 그래야만 최소의 표현으로 많은 것을 나태낼 수 있다.
- 아래의 코드를 보자
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.getContext();
assertEquals("test/xml", response.getContextType());
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"));
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.getContext();
assertEquals("text/xml", response.getContextType());
assertSubString("test page", xml);
assertSubString("<Test", xml);
}
- 위의 코드는 개선할 여지가 많다.
- 먼저 addPage, assertSubString 이라는 코드가 중복적으로 등장한다.
- 그로인해 자질구레한 사항이 많아 코드의 표현력이 떨어진다.
- 또한 문자열을 pagePath 인스턴스로 변환하는 PathParser 또한 의도를 흐린다.
- 그 뿐만아니라 responder 객체를 생성하는 코드와 response를 수집해 변환하는 코드 역시 잡음에 불과하다.
- 위의 코드는 아래와 같이 리팩토링 될 수 있다.
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 패턴이 위와 같은 테스트 구조에 적합하다.
- 각 테스트를 3가지 부분으로 나눈다.
- 테스트 자료를 만든다.
- 테스트 자료를 조작한다.
- 조작한 결과가 올바른지 확인한다.
- 이렇게 리팩토링된 코드는 도메인에 특화된 언어(DSL)라고 할 수 있다.
- 시스템 조작 API를 사용하는 대신 API 위에다 함수와 유틸리티를 구현한 후 그 함수와 유틸리티를 사용한다.
- 테스트 코드를 짜기도 읽기도 쉬워진다.
- 현한 함수와 유틸리티는 테스트 코드에서 사용하는 특수 API가 된다.
- DSL은 잡다하고 세세한 사항으로 번벅된 코드를 계속 리팩토링해서 진화한 API이며, 나중에 테스트를 읽어볼 독자를 도와주는 언어이다.
이중 표준
- 테스트 API 코드에 적용하는 표준은 실제 코드에 적용하는 표준과 확실히 다르다.
- 단순하고, 간결하고 표현력이 풍부해야 하지만, 실제 코드만큼 효율적일 필요는 없다.
- 실제 환경이 아니라 테스트 환경에서 돌아가는 코드이기 때문이다.
- 아래의 코드를 보자
@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());
}
- 딱봐도 세세한 사항이 아주 많다.
- 각각의 상태를 보고 그 상태가 True 인지 False 인지 봐야한다.
- 결국 읽기가 어렵고 따분하다.
- 아래와 같이 리팩토링하면 어떨까
@Test
public void turnOnLoTempAlarmThreshold() throws Exception {
wayTooCold();
assertEquals("HBchL", hw.getState());
}
- HBchL: (heater, blower, coolear, hi-temp-alarm, lo-temp-alarm) 이며 대소문자로 꺼진것과 켜진 것을 구분해서 한눈에 알아 보기 쉽도록 하였다.
- 물론 이렇게 코드를 축약해서 표현하는 것을 지양해야하지만, 특수한 경우 다음과 같이 오히려 좋은 경우가 있다.
- 아래와 같이 상태에 다양한 테스트를 한다고 했을때 한눈에 코드가 읽히기 때문이다.
@Test
public void turnOnCollerAndBlowerIfTooHot() throws Exception {
tooHot();
assertEquals("hBChl", hw.getState());
}
@Test
public void turnOnGeaterAndBlowerIfTooCold() throws Exception {
tooCold();
assertEquals("HBchl", hw.getState());
}
@Test
public void turnOnHiTempAlarmAtThreshold() throws Exception {
wayTooGot();
assertEquals("hBCHl", hw.getState());
}
@Test
public void turnOnLoTempAlarmThreshold() throws Exception {
wayTooCold();
assertEquals("HBchL", hw.getState());
}
테스트 당 assert 하나
- 테스트 당 assert 하나 보다는 테스트 함수마다 한 개념만 테스트하라라는 규칙이 더 좋다.
- 또한 개념당 assert 문 수를 최소로 줄이는 것이 좋다.
- 잡다한 개념을 연속으로 테스트하는 것은 피해야한다.
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());
}
- 위의 코드는 3가지 개념을 테스트하는 코드이다.
- 개념1: (6월처럼) 30일로 끝나는 한 달을 더하면 날짜는 30일이 되어야지 31일이 되어서는 안된다.
- 개념2: 두 달을 더하면 그리고 두 번째 달이 31일로 끝나면 날짜는 31일이 되어야 한다.
- 개념3: 31일로 끝나는 한 달을 더하면 날짜는 30일이 되어야지 31일이 되어서는 안된다.
- 개념 3개를 각각 분리해서 1개의 개념씩 테스트 하는 것이 좋다.
FIRST (깨끗한 테스트를 짜기위한 5가지 규칙)
- Fast
- 테스트는 빨리 돌아야 한다.
- 테스트가 느리면 자주 돌림 엄두가 나지 않고, 초반에 문제를 찾아 고치지 못한다.
- 결과적으로 코드 품질이 망가지게 된다.
- Independent
- 각 테스트는 서로 의존하면 안된다.
- 각 테스트는 독립적으로, 어떤 순서로 실행해도 괜찮아야 한다.
- Repeatable
- 테스트는 어떤 환경에서도 반복 가능해야 한다.
- 테스트가 돌아가지 않는 환경이 하나라도 있다면 테스트가 실패할 이유를 둘러댈 변명이 생긴다.
- Self-Validating
- 테스트는 성공 아니면 실패로 결과를 내야 한다.
- 통과 여부를 알려고 로그 파일을 읽게 만들어서는 안된다.
- 테스트가 스스로 성공과 실패를 가늠하지 않는다면 판단은 주관적이 되며 지루한 수작업 평가가 필요하게 된다.
- Timely
- 테스트는 적시에 작성해야 한다.
- 단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현한다.
- 실제 코드를 구현한 다음에 테스트 코드를 만들면 테스트가 어렵도록 실제 코드를 설계할 수도 있다.
REFERENCES