코드
https://github.com/rkwhr0010/clean_code/tree/main/src
변경 사항은 git history 참고
테스트 코드는 일회용이 아니다. 단위 테스트는 자동화되어야 한다.
TDD 법칙 세 가지
실제 코드를 짜기 전에 단위 테스트부터 짜라
- 첫재 법칙
실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다 - 둘째 법칙
컴파일은 성공하면서 실행이 실패하는 정도로만 단위 테스트를 작성한다 - 셋째 법칙
현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.
위 법칙을 따르면 개발과 테스트가 대략 30초 주기로 묶인다.
테스트 코드와 실제 코드가 함께 나온다.
특히, 테스트 코드가 실제 코드가 나오기 직전에 같이 나온다.
이 방식으로 일하면, 시간이 지남에 따라 실제 코드와 맞먹는 수준으로 무수한 테스트 케이스가 나온다.
너무 방대한 테스트 코드는 심각한 관리 문제를 유발하기도 한다.
깨끗한 테스트 코드 유지하기
지저분한 테스트 코드는 테스트를 안 하느니만 못하다.
실제 코드가 진화하면 테스트 코드도 변해야 한다.
테스트 코드가 지저분할수록 변경이 어려워, 실제 코드를 짜는 시간보다 테스트 케이스를 추가하는 시간이 더 걸린다.
실패 케이스가 발생하기 시작하면, 지저분한 코드로 인해, 실패하는 테스트 케이스를 점점 더 통과시키기 어려워진다. 이는 점점 더 큰 부담으로 다가와
이 테스트 슈트는 결국 폐기를 할 수 밖에 없어진다.
하지만, 테스트 슈트가 없으면 개발자는 자신이 변경한 코드가 제대로 도는지 확인할 방법이 없다.
테스트 코드가 지저분함 => 변경이 어려워 짐 => 실제 코드보다 테스트 케이스 추가가 시간이 더 걸림 => 실패 케이스 발생 => 지저분한 테스트 코드라 통과시키기 어려움 => 시간이 지남에 따라 지저분한 테스트 코드는 더 늘음 => 개발자에게 큰 부담 => 테스트 슈트 폐기 => 테스트할 방법이 없음 => 결함율 증가 => 변경을 꺼기게 됨 => 실제 코드가 망가지기 시작
결국 지저분하게 관리된 테스트 코드는 거기에 들어간 노력이 0 이 되버린다.
원인은 테스트 코드를 막 짠 것이다.
테스트 코드는 일급 시민이다. 실제 코드와 동등한 수준으로 깔끔해야 한다.
아니면 결국 잃어버린다
테스트는 유연성, 유지보수성, 재사용성을 제공한다
단위 테스트 케이스는 실제 코드에 버팀목
테스트 케이스가 있어야 변경이 두렵지 않다.
개발자가 변경하기 두려운 근본적인 원인은 변경하다가 오류가 날까봐
테스트 케이스가 없다면, 모든 변경이 잠정적인 버그다.
테스트 커버리지가 높을 수록 공포는 줄어든다.
테스트 코드가 깨끗 => 테스트가 쉬움 => 실제 코드 변경이 쉬움
비즈니스 요구사항에 빠른 대처를 위해선 테스트 코드를 관리해야 하는 것은 필수
깨끗한 테스트 코드
깨끗한 테스트 조건 가독성
가독성 조건, 명료성, 단순성, 풍부한 표현력
테스트 코드는 최소 코드로 최대 표현력을 줘야한다.
예시
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는 문자열을 pagePath 인스턴스로 변환한다.
pagePath는 크롤러가 사용하는 객체다.
테스트하려는 본질과는 무관한 코드다. 오하려 테스트 의도만 흐린다.
responder 또한 테스트 본질과는 무관한 코드다.
위 코드는 결정적으로 읽는 사람을 전혀 고려하지 않는 테스트 코드다.
독자(다른 개발자, 심지어 미래의 자신)는 본질과 관계없는 잡다한 코드를 읽으며 간신히 테스트 코드를 이해하게 된다.
리팩터링
public void testGetPageHieratchyAsXml() throws Exception { makePages("PageOne", "PageOne.ChildOne", "PageTwo"); submitRequest("root", "type:pages"); assertResponseIsXML(); assertResponseContains("<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"); } public void testGetPageHieratchyAsXmlDoesntContainSymbolicLinks() throws Exception { WikiPage pageOne = crawler.addPage(root, PathParser.parse("PageOne")); makePages("PageOne.ChildOne", "PageTwo"); addLinkTo(pageOne, "SymPage", "PageTwo"); submitRequest("root", "type:pages"); assertResponseIsXML(); assertResponseContains("<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>"); assertResponseDoesNotContain("SymPage"); } public void testGetDataAsHtml() throws Exception { makePageWithContent("TestPageOne", "test page"); submitRequest("TestPageOne", "type:pages"); assertResponseIsXML(); assertResponseContains("test page", "<Test"); } |
BuildOperateCheck 패턴이 위와 같은 테스트 구조에 적합
http://butunclebob.com/FitNesse.BuildOperateCheck
각 테스트는 세 부분으로 나뉜다.
- 첫 부분 테스트 자료 생성
- 두 번째 부분 테스트 자료 조작
- 세 번째 부분 조작한 결과 정상인지 확인
잡다하고 세세한 코드를 제거하고, 테스트하고자 하는 본질을 노출한다.
이제 누가봐도 빠르게 이 테스트가 무엇을 하는지 알 수 있다.
도메인에 특화된 테스트 언어
직전 예시는 DSL로 테스트 코드를 구현한 기법이다.
시스템 조작 API 대신 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()); } |
tic() 메서드가 뭔지
상태 이름과 상태 값을 확인하느라 시선이 분산된다.
hw.headterState() 확인 후 assertTrue를 보게 된다.
리팩터링
@Test public void turnOnLoTempAlarmAtThreashold() throws Exception { wayTooCold(); assertEquals("HBchL", hw.getState()); } |
public String getState() { String state = ""; state += heater ? "H" : "h"; state += bowler ? "B" : "b"; state += cooler ? "C" : "c"; state += hiTempAlram ? "H" : "h"; state += loTempAlram ? "L" : "l"; return state; } |
tic()함수는 wayTooCold()안으로 숨겼다.
그리고, 대문자가 on, 소문자가 off를 표현하도록 했다.
순서는 heat, blower, cooler, hi-temp-alram, lo-temp-alram 이다.
위 방식이 그릇된 정보를 피하라 규칙을 위반하지만, 여기선 적절해 보인다.
getState() 메서드는 효율을 위해선 StringBuffer가 더 좋으나
사용법이 보기가 흉하다.
이 코드는 테스트 환경이므로, 컴퓨팅 자원이 충분하다. 보기 좋은 방식으로 사용했다.
이것이 이중 표준의 본질이다. 실제 환경에서는 절대 안되지만 테스트 환경에선 문제가 없는 방식이 존재한다.
특히, CPU, 메모리 같은 자원의 경우 개발 코드와는 전혀 무관하다.
테스트 당 assert 하나
Junit 으로 테스트 코드를 짤 때는 함수마다 assert 문을 하나만 사용해야 한다는 규칙은 테스트 코드를 이해하기 쉽게 만든다.
public void testGetPageHierachyAsXml() throws Exception { givenPages("PageOne", "PageOne.ChildOne", "PageTwo"); whenRequestIsIssured("root", "type:pages"); thenResponseShouldBeXML(); } public void testGetPageHierarchyHasRightTags() throws Exception { givenPages("PageOne", "PageOne.ChildOne", "PageTwo"); whenRequestIsIssured("root", "type:pages"); thenResponseShouldContain( "<name>PageOne</name>", "<name>PageTwo</name>", "<name>ChildOne</name>" ); } |
given - when - then 패턴
- given
테스트 상태를 설명 - when
구체화하고자 하는 해동 - then
예상되는 변화
위 패턴을 사용하면 테스트를 읽기 쉬워진다.
다만, 테스트를 분리하면 중복이 많아진다.
이 중복은 템플릿 메서드 패턴으로 제거할 수 있다.
중복되는 given, when 부분을 부모로 두고, then 부분을 상속 후 구현하면 된다.
혹은 Junit 프레임워크 도움을 받아 @Before 어노테이션으로 처리해도 된다.
결국 이것저것 할 일이 많아진다. 단일 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()); assertE |
세 개의 개념을 테스트를 하고 있다.
독자적인 테스트 세 개로 쪼개야 한다.
개념 당 assert 문 수를 치소로 줄여라 그리고 테스트 함수 하나는 개념 하나만 테스트하라
FIRST
Fast(속도)
테스트는 빨라야 한다. 그래야 자주 돌린다.
느리면, 자주 못돌려 테스트 돌리기를 주저하게 된다. 결국 코드 품질이 망가진다.
Indepencent(독립)
각 테스트 간 의존은 없어야 한다.
각 테스트 간 순서를 뒤섞어도 아무런 문제가 없어야 한다.
의존이 생기는 순간 하나 실패 시 줄줄이 실패한다. 더욱이 문제를 찾기 힘들어 진다. (그 뒤에 진짜 결함이 숨겨질 수도 있다)
Repeatable(반복)
어떤 환경에서도 반복적으로 테스트 가능해야 한다.
심지어 인터넷이 안되는 환경에도 가능해야 한다.
예외를 두기 시작하면, 테스트 실패 이유에 변명만 늘어난다
Self-Validating(자가검증)
테스트는 boolean값을 반환해야 한다.
성공, 실패 단 두가지, 그 사실을 알려고, 로그를 뒤지면 안된다
테스트 작성 시 명확히 실패, 성공 케이스를 판단해야 한다.(애매한게 있으면 안된다)
Timely (적시)
단위 테스트는 테스트 하려는 실제 코드 구현 직전에 구현한다.
실제 코드를 구현하고, 테스트 코드를 만들면, 실제 코드가 테스트하기 어려운 구조라는 것을 뒤늦게 발견할 수도 있다. 더 나아가 테스트가 불가능하도록 실제 코드를 설계할 수도 있다.
결론
- 테스트는 실제 코드만큼 중요하다.
- 좋은 테스트 코드는 실제 코드의 유연성, 유지보수성, 재사용성을 보증한다.
- 표현력을 높이고 간결히 정리해야 한다. 테스트 하기 쉽도록 테스트 API를 구현해 DSL를 만든다.
- 테스트 코드가 망가지면, 결국 실제 코드도 망가진다.
'IT책, 강의 > 클린코드(Clean Code)' 카테고리의 다른 글
11장 시스템 (0) | 2024.01.22 |
---|---|
10장 클래스 (1) | 2024.01.15 |
8장 경계 (1) | 2024.01.01 |
7장 오류 처리 (0) | 2023.12.25 |
6장 객체와 자료 구조 (1) | 2023.12.18 |