여러분은 테스트 코드를 작성하시나요? 이번 장에서는 테스트 코드의 중요성과 테스트 코드 작성하는 법을 간단하게 소개하려고해요. 저자에 따르면 테스트 코드에 대해서 설명하려면 책 1권으로도 부족하다고 말하니, 자세한 설명은 아니고 핵심만 설명해요.
유명한 TDD 법칙 3가지가 있어요.
실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.
컴파일은 실패하지 않으면서 실행이 실패하는 정도로 단위 테스트를 작성한다.
현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.
이 규칙들을 따르면 테스트 코드와 실제 코드가 함께 나오는 장점을 가질 수 있어요. 그리고 개발과 테스트가 대략 30초 주기로 묶이고 시간이 지나며 수천 개에 달하는 테스트 케이스가 나와요.
그런데 이렇게 방대해지는 테스트 코드는 심각한 관리 문제를 일으키기도 해요.
테스트 코드와 실제 코드의 동일한 기준으로 품질을 유지할 필요는 없어요. 하지만 기준이 약간 다를 뿐 테스트코드도 고품질을 유지해야해요. 대표적으로 단위 테스트에서는 규칙을 깨고 작성해도 되도록 허가해줘서 코드가 망가지는 것이에요. 지저분한 테스트 코드를 사용하는 것은 테스트 코드를 짜지 않는 것보다 오히려 안좋을 수 있어요. 대충하면 오히려 안하는 것만 못한거에요.
테스트 코드는 유연성, 유지보수성, 재사용성을 제공해요. 그래서 테스트 코드를 깨끗하게 유지하지 않으면 이런 장점도 잃어버려요. 테스트 코드가 있기 때문에 코드 변경을 더 자유롭게 할 수 있었던 장점이 있는데, 테스트 코드를 깨끗하게 유지하지 못하면 장점이 사라지고 실제 코드도 함께 망가지는 거에요.
테스트 코드를 만들기위해 필요한 것은 딱 1가지 "가독성"이에요. 어느 코드들과 같이 명료성, 단순성, 풍부한 표현력이 필요해요. 최소의 표현으로 많은 것을 나타내야해요. 다음 테스트 케이스를 보면서 이야기 해요.
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);}위 테스트 코드는 자질구레한 사항을 너무 많이 표현하고 중복되는 코드도 많아서 표현력이 떨어져요. 의도를 바로 알아볼 수 없고, 상관없는 crawler같은 객체를 알아야 한다거나, 데이터 흐름도 많이 파악해야해요. 그래서 잡음이 많아요. 이를 좀더 깨끗하게 개선해봐요.
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");}이제 바뀐 코드를 보면 어떠신가요? testGetPageHAierarchyAsXml 테스트 코드를 보면, [makePages(페이지 생성) -> submitRequest(요청 보내기) -> 응답 검증]의 흐름으로 테스트를 하고 있음을 알 수 있어요. 테스트에 필요한 자료 유형과 함수만 사용하고 나머지가 다 빠져서 이상한 잡음없이 테스트 코드가 잘 읽혀요.
그리고 위와 같이 3단계로 이루어진 구조는 보통 BUILD-OPERATE-CHECK 패턴을 활용하여 가독성을 늘려요. 그리고 이 패턴을 활용할 때는 given-when-then을 관례적으로 주석으로 이용하거나 함수명에 추가해서 구분해줘요.
BUILD(given) : 테스트 자료 생성
OPERATE(when) : 테스트 자료 조작
CHECK(then) : 조작 결과 확인
그리고 위 개선된 코드를 보면 직접 조작하는 API를 사용하지 않고, API 위에 함수와 유틸리티를 구현하여 사용하고 있어요. 이렇게 테스트를 위한 도메인 특화 언어(DSL)를 활용하면 테스트를 읽을 독자가 이해하기 쉬워져요. 이런 언어를 테스트 언어라고 해요. 그래서 위 개선처럼 숙련된 개발자는 좀더 간결하고 표현력이 풍부한 코드로 리팩터링 해야해요. 그런데 사실 이런 용어들을 몰라도 공부한거에 따라서 하다보면 위와같은 코드가 나올거라고 생각해요.
실제 코드나 테스트 코드나 품질이 좋아야해요. 하지만 그렇다고 그 기준이나 표준이 같을 필요는 없어요. 예를 들면 실제 코드와 테스트 코드는 실제 실행되는 환경이 다를 확률이 매우 높기 때문에 너무 성능을 중요시하고 짤 필요는 없어요. 예를 들면 굳이 어떤 테스트를 위한 문자열을 만들때 String 자료형 대신 StringBuffer를 쓸 필요가 없어요. 우리는 문자열 연산을 여러번 해야할 때 StringBuffer를 이용하면 훨씬 메모리 효율적이라는 것을 알지만, 테스트 환경은 실제 환경과 달리 자원이 제한될 확률이 적기 때문에 고려할 필요가 없어요. 물론 만약 무리가 되는 코드라면 바꿔주는게 좋겠죠. 이와 같은 이유 때문에 테스트 코드와 실제 코드는 메모리나 CPU 효율에 관련 있는 경우의 품질은 무시해도 되고, 서로 다른 표준을 가질 수 있어요.
assert를 반드시 1개만 사용해야 한다고 주장하기도 해요. 그런데 실제로 결론이 1개인 함수는 코드를 이해하기 쉽고 빨라요. 위 코드중 일부를 예시로 다시 가져와 볼게요.
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>");}위 코드는 "출력이 XML이다.", "특정 문자열을 포함한다."는 assert문 2개를 가지고 있어요. 그러면 이를 쪼갤 수 있어요.
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, @test 부분을 나눠서 만들어도 좋아요. 그런데 사실 이것저것 따져보면 그냥 중복으로 하는게 훨씬 좋다고 저자가 생각한데요.
테스트 당 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개로 쪼개봐요.
30일로 끝나는 한 달을 더하면 날짜는 30일이 되어야지 31일이 되어서는 안 된다.
두 달을 더하면 그리고 두 번째 달이 31일로 끝나면 날짜는 31일이 되어야 한다.
31일로 끝나는 한 달을 더하면 날짜는 30일이 되어야지 31일이 되면 안된다.
이렇게 보면 훨씬 테스트 케이스가 명확하게 구분되었고, 28일에 대해서는 고려한 것인지도 쉽게 생각나서 채워줄 것 같아요.
그래서 결론은 "개념 당 assert 문 수를 최소로 줄여라"와 "테스트 함수 하나는 개념 하나만 테스트하라"에요.
깨끗한 테스트는 다섯 가지 규칙을 따라요. 그리고 각 규칙에서 첫 글자를 따면 FIRST가 되요.
Fast(빠르게): 테스트는 빨리 돌아야해요. 그래야 테스트를 자주 돌릴 엄두가 나고 문제를 찾아내 고칠 수 있어요.
Independent(독립적으로): 각 테스트는 서로 의존하면 안돼요. 한 테스트가 다음 테스트가 실행될 환경을 준비하면 안돼요. 테스트간 의존이 생기고 순서가 생기면 하나가 실패할 때 연달아 실패하고 결함 찾기가 힘들어질 거에요.
Repeatable(반복가능하게): 테스트는 어떤 환경에서도 반복 가능해야해요. 실제 환경, QA 환경, 네트워크 안되는 환경에서도 실행할 수 있어야 해요. (개인적인 사견으로 인터넷이 안되는게 오히려 이상한 현시대에서 모든 환경을 고려해줘야할까?? 라는 의구심이 든다.)
Self-Validating(자가검증하는): 테스트는 bool값으로 결과를 내야해요. 성공 or 실패 둘중 하나가 반드시 나와야해요. 성공/실패를 판단하기 위해 어떤 로그를 읽게 만든다거나 하는 방식은 안되요. 알아서 판단되어 객관적인 결과를 도출해줘야 해요.
Timely(적시에): 테스트는 적시에 작성해야해요. 단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현해요. 실제 코드를 구현하고 테스트 코드를 만들면 실제 코드가 테스트하기 어려워질거에요. 테스트하기 불가능하도록 실제 코드가 설계될지도 몰라요.
이번 장에서는 수박 겉핥기 정도로만 깨끗한 테스트 코드에 대해 소개했어요. 테스트 코드는 실제 코드보다 중요할 수도 있어요. 실제 코드의 유연성, 유지보수성, 재사용성을 보존하고 강화시켜주기 때문이에요. 그렇기 때문에 테스트 코드를 지속적으로 관리하고 표현력을 높이고 간결하게 정리해야해요. 그리고 테스트 API를 구현해 도메인 특화 언어(DSL)을 만들어야해요. 그러면 테스트 코드를 작성하기도 쉬워질 거에요.
테스트 코드가 망가지면 실제 코드도 망가지게되요. 테스트 코드를 깨끗하게 유지합시다!!
그런데 계속 1장부터 쭉 올라오면서 느낀 것이 있지 않나요?? 모든 것은 분리와 가독성이 중심이라고 항상 느껴요. 그리고 분리와 가독성을 저절로 할 정도의 마인드가 있는 개발자라면 좀더 그 마인드를 구체화할 수 있는 책이라고 느껴지네요.