- [BOOK - 클린코드] Chapter 9. 단위 테스트2025년 02월 28일 17시 00분 10초에 업로드 된 글입니다.작성자: nickhealthy
이전에는 TDD(Test Driven Development) 개념이 생소하였고, 단위 테스트란 '프로그램이 돌아간다'는 일회성 코드에 불과했다. 하지만 소프트웨어 분야가 발전하면서 테스트 코드의 중요성은 높아졌고, 지금은 꼬치꼬치 따지며 테스트 코드를 작성해야 한다.
이번 장에서는 테스트 코드를 아주 얕게 배워본다. 저자는 테스트 코드를 제대로 설명하려면 책 한 권으로도 부족하다고 한다.
TDD 법칙 세 가지
TDD(Test-Driven Development)는 테스트를 먼저 작성하고, 그 테스트를 통과하는 최소한의 코드를 작성하는 방식으로 소프트웨어를 개발하는 방법론이다. 일반적인 개발 방식과는 달리, 기능을 구현하기 전에 테스트를 먼저 작성하는 것이 핵심이다.
중요한 세 가지 법칙(Red-Green-Refactor 사이클)
이 규칙은 빙산의 일각에 불과하지만 다음과 같은 중요한 세 가지 법칙이 있다.
- 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.
- 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
- 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.
이런 방식으로 TDD 법칙을 적용하면 테스트 코드는 물론, 실제 코드도 금방 적용할 수 있다.
TDD의 특징
- 버그 감소 및 유지보수 용이
- 기능을 개발하면서 바로 테스트를 검증하기 때문에 초기 버그를 빠르게 발견할 수 있다.
- 자동화된 테스트 코드가 남아 있기 때문에, 나중에 코드 변경이 있어도 안정성을 유지할 수 있다.
- 보다 명확한 요구사항 반영
- 테스트 코드가 요구사항을 기반으로 작성되므로, 개발자는 기능이 정확히 어떻게 동작해야 하는지를 고민하며 개발하게 된다.
- 결과적으로 코드가 명확해지고 불필요한 기능이 추가되는 것을 방지할 수 있다.
- 리팩토링을 안전하게 수행 가능
- 리팩토링 후에도 기존 테스트가 유지되므로, 기능이 정상적으로 동작하는지를 확인할 수 있다.
- 코드 변경에 대한 두려움 없이 유지보수할 수 있다.
- 개발 속도 향상 (장기적인 관점)
- 초기에는 테스트 코드 작성으로 인해 개발 속도가 느려질 수 있다.
- 하지만, 이후 버그 수정 시간 감소, 유지보수 용이성 등으로 인해 장기적으로 개발 속도가 더 빨라지는 효과가 있다.
TDD의 단점
- 초기 개발 속도가 느릴 수 있음
- 테스트를 먼저 작성해야 하므로 처음에는 시간이 더 걸릴 수 있다.
- 테스트 코드 유지보수 필요
- 기능이 변경될 때마다 테스트 코드도 함께 수정해야 한다.
- 복잡한 UI 개발에는 적용이 어려움
- TDD는 백엔드 로직이나 API 개발에는 적합하지만, UI 중심의 애플리케이션에서는 적용이 어려운 경우가 많다.
정리
- TDD는 "테스트를 먼저 작성하고, 이를 통과하는 코드를 구현하는 방식"
- Red-Green-Refactor 사이클을 반복하면서 개발 진행
- 버그 감소, 유지보수 용이, 리팩토링 안정성 확보 등의 장점
- 초기 속도 저하, 테스트 코드 유지보수 부담 등의 단점도 존재
결국, 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.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
코드가 중복되는 부분이 상당히 많다. 그리고 테스트와는 무관한 코드가 많아 코드의 의도를 흐리게 된다. 코드를 읽는 사람은 온갖 무관한 코드를 이해한 후에야 간신히 테스트 케이스를 이해할 수 있다.✅ 옳은 방법)
조금 더 이해하기 쉬운 테스트 코드로 바꿔보자.
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 패턴이 위와 같은 테스트 구조에 적합하다. 각 테스트는 명확히 세 부분으로 나뉜다.
- 첫 번째 부분은 테스트 자료를 만든다.
- 두 번째 부분은 테스트 자료를 조작한다.
- 세 번째 부분은 조작한 결과가 올바른지 확인한다.
- 더욱 중요한 사실은 세세하고 잡다한 코드는 제외하고, 테스트에 필요한 자료 유형과 함수만 사용하는 것이 중요하다.
도메인에 특화된 테스트 언어
위에서 본 ✅ 옳은 방법) 예제처럼 테스트 코드를 짜기 위해 특수 API를 만들기도 한다.
특수 API란, 흔히 쓰는 시스템 조작 API를 사용하는 대신 API 위에 함수와 유틸리티를 구현한 후, 그 함수와 유틸리티를 사용하는 것을 의미한다. 이런 특수 API를 사용하게 되면 테스트 코드를 짜기도, 읽기도 쉬워진다.이중 표준
실제 환경에서는 절대로 하면 안 되는 것이지만, 테스트 환경에서는 전혀 문제가 없는 방식을 '이중 표준'이라고 한다.
- 테스트 API 코드에 적용하는 표준은 실제 코드에 적용하는 표준과 다르다.
- 아래 예제처럼
assertEquals
의 이상한 문자열은 'chapter 3. 그릇된 정보를 피하라' 규칙 위반에 가깝지만 의미만 명확히 파악할 수 있다면 테스트 케이스에서는 사용해도 괜찮다. - 참고로 아래 대소문자의 기준은 여러 항목에 대한 on/off 의 상태 값을 첫 글자만 따와 대소문자로 구분한 것이다.
- 아래 예제처럼
- 테스트 코드는 조금은 효율적이지 않아도 된다.
- 예를 들어, 알고리즘과 자료구조를 선택하는 것에 따라 효율성에 차이가 있지만 미미한 차이라면 괜찮다.
- 또 다른 예시로 컴퓨팅 자원이 빡빡한 실제 환경과는 다르게 테스트 환경은 자원이 제한적이지 않을 가능성이 높다.
테스트 당 assert 하나
- 함수마다 assert 문을 단 하나만 사용해야 한다고 주장하는 사람들도 있다.
- 가혹한 규칙이라 여길지도 모르지만 확실히 장점이 있다.
- assert 문이 단 하나인 함수는 결론이 하나이기 때문에 코드를 이해하기 쉽고 빠르다.
- 개념이 같다면 한 함수에 assert를 여러 개 사용해도 괜찮다.
- 단지 assert 문의 개수를 최대한 줄여야 한다는 것에는 동의한다.
- 테스트 함수마다 한 개념만 테스트해야 한다.
가장 좋은 규칙은 개념 당 assert 문을 최소로, 테스트 함수 하나는 개념 하나만 테스트하라
F.I.R.S.T
깨끗한 테스트는 다음과 같은 다섯 가지 규칙을 따른다. 각 규칙의 앞 글자를 따오면 FIRST가 된다.
- 빠르게(FAST)
- 테스트는 빨라야 한다. 즉, 빠르게 동작해야 한다.
- 테스트가 느리면 자주 돌릴 엄두를 못 낸다.
- 독립적으로(Independent)
- 각 테스트는 서로 의존하면 안 된다.
- 각 테스트는 독립적으로, 어떤 순서로 실행해도 괜찮아야 한다.
- 테스트가 서로에게 의존하면 하나가 실패할 때 나머지도 잇달아 실패하므로 원인을 진단하기 어려워지고, 뒤늦게 나오는 테스트는 결함이 숨겨지게 된다.
- 반복 가능하게(Repeatable)
- 테스트는 어떤 환경에서도 반복 가능해야 한다.
- 실제 환경, QA, 네트워크 없는, 모든 환경에서 가능해야 한다.
- 자가 검증하는(Self-Validating)
- 테스트는 부울(
bool
) 값으로 결과를 내야 한다. 즉, 성공 또는 실패다. - 통과 여부를 알려고 로그 파일이나, 텍스트 파일 두 개를 수작업으로 비교하게 만들면 안 된다.
- 이를 지키지 않으면 판단이 주관적이게 되며, 지루한 수작업 평가가 된다.
- 테스트는 부울(
- 적시에(Timely)
- 테스트는 적시에 작성해야 한다. 단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현한다.
- 실제 코드를 구현한 다음에 테스트 코드를 만들면, 테스트가 불가능하도록 실제 코드를 설계하거나 판단해 버릴 수 있다.
결론
- 테스트 코드는 실제 코드 만큼이나 프로젝트 건강에 중요하다.
- 실제 코드의 유연성, 유지 보수성, 재사용성을 보존하고 강화하기 때문이다.
- 그러므로 실제 코드와 마찬가지로 테스트 코드도 지속적으로 관리 되어야 한다.
- 표현력을 높이고 간결하게 테스트 코드를 작성해라.
- 테스트 API를 구현해 도메인 특화 언어(DSL)을 만들자. 테스트 코드를 작성하고 이해하는데 훨씬 수월하다.
다음글이 없습니다.이전글이 없습니다.댓글