[Clean Code] 9. 단위 테스트

2023. 7. 24. 17:42·Reference/CleanCode
목차
  1. 1. TDD 법칙 세 가지
  2. 2. 깨끗한 테스트 코드 유지하기
  3. 3. 깨끗한 테스트 코드
  4. 4. 테스트 당 assert 하나
  5. 5. F.I.R.S.T
📕 목차

1. TDD 법칙 세 가지
2. 깨끗한 테스트 코드 유지하기
3. 깨끗한 테스트 코드
4. 테스트 당 assert 하나
5. F.I.R.S.T

1. TDD 법칙 세 가지

 

  1. 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.
  2. 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
  3. 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.

공감이 안 간다면 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 패턴이 위와 같은 테스트 구조에 적합하다.
    1. 테스트 자료를 만든다
    2. 테스트 자료를 조작한다
    3. 조작한 결과가 올바른지 확인한다.
  • 테스트 코드는 본론에 돌입해 진짜 필요한 자료 유형과 함수만을 남기고 모두 없애라.

 

✒️ 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 패턴을 사용할 수 있다.
      1. given/when 부분을 부모 클래스에 두고 then 부분을 자식 클래스에 두는 방법
      2. @Before 함수에 given/when 부분을 넣고 @Test 함수에 then 부분을 넣는 부분
    • 뭐가 됐건 배보다 배꼽이 커진다.

'단일 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일로 끝나는 달의 마지막 날짜가 주어지는 경우
      1. (6월처럼) 30일로 끝나는 한 달을 더하면 날짜는 30일이 되어야지 31일이 되어서는 안된다.
      2. 두 달을 더하면 그리고 두 번쨰 달이 31일로 끝나면 날짜는 31일이 되어야 한다.
    • (6월처럼) 30일로 끝나느 달의 마지막 날짜가 주어지는 경우
      1. 31일로 끝나는 한 달을 더하면 날짜는 30일이 되어야지 31일이 되면 안 된다.
  • 위 코드의 문제점은 assert 문이 여럿인 게 아니라, 여러 개념을 하나의 함수에서 테스트한다는 사실이다.

 


5. F.I.R.S.T

 

📌 빠르게 (Fast)
  • 테스트는 빨라야 한다.
  • 테스트가 느리면 자주 돌릴 엄두를 못낸다.
  • 자주 돌리지 않으면 초반에 문제를 찾아내 고치지 못한다.

 

📌 독립적으로 (Independent)
  • 각 테스트는 서로 의존하면 안 된다.
  • 한 테스트가 다음 테스트가 실행될 환경을 준비해서는 안 된다.
  • 각 테스트는 독립적으로 어떤 순서로 실행해도 괜찮아야 한다.
  • 테스트가 의존하면 하나가 실패하면 잇달아 실패한다.
    • 원인을 진단하기 힘들어지고 후반 테스트가 찾아내야 할 결함시 숨겨진다

 

📌 반복가능하게 (Repeatable)
  • 어떤 환경에서도 반복 가능해야 한다.
  • 실제 환경, QA 환경, 네트워크가 연결되어 있지 않은 노트북에서도 실행되어야 한다.

 

📌 자가검증하는 (Self-Validating)
  • 테스트는 bool값으로 결과를 내야 한다. (성공 아니면 실패만이 존재한다.)
  • 통과 여부를 알려고 로그 파일을 읽게 만들면 안 된다.
  • 통과 여부를 보려고 텍스트 파일 두 개를 수작업으로 비교하게 만들어서도 안 된다.
  • 테스트는 스스로 성공과 실패를 가늠해야 한다.

 

📌 적시에 (Timely)
  • 단위 테스트는 테스트하려는 실제 코드를 구현하기 직전에 구현한다.
  • 실제 코드를 구현한 다음에 만들면 실제 코드가 테스트하기 어렵다는 사실을 발견할 수도 있다.
  • 어떤 실제 코드는 테스트하기 너무 어렵다고 판명날지 모른다.
  • 테스트가 불가능하도록 실제 코드를 설계할지도 모른다.
저작자표시 비영리 (새창열림)
  1. 1. TDD 법칙 세 가지
  2. 2. 깨끗한 테스트 코드 유지하기
  3. 3. 깨끗한 테스트 코드
  4. 4. 테스트 당 assert 하나
  5. 5. F.I.R.S.T
'Reference/CleanCode' 카테고리의 다른 글
  • Clean Code 보류
  • [Clean Code] 10. 클래스
  • [Clean Code] 8. 경계
  • [Clean Code] 7. 오류 처리
나죽못고나강뿐
나죽못고나강뿐
싱클레어, 대부분의 사람들이 가는 길은 쉽고, 우리가 가는 길은 어려워요. 우리 함께 이 길을 가봅시다.
  • 나죽못고나강뿐
    코드를 찢다
    나죽못고나강뿐
  • 전체
    오늘
    어제
    • 분류 전체보기 (458)
      • Computer Science (60)
        • Git & Github (4)
        • Network (17)
        • Computer Structure & OS (13)
        • Software Engineering (5)
        • Database (9)
        • Security (5)
        • Concept (7)
      • Frontend (21)
        • React (13)
        • Android (4)
        • iOS (4)
      • Backend (77)
        • Spring Boot & JPA (50)
        • Django REST Framework (14)
        • MySQL (8)
        • Nginx (1)
        • FastAPI (4)
      • DevOps (24)
        • Docker & Kubernetes (11)
        • Naver Cloud Platform (1)
        • AWS (2)
        • Linux (6)
        • Jenkins (0)
        • GoCD (3)
      • Coding Test (112)
        • Solution (104)
        • Algorithm (7)
        • Data structure (0)
      • Reference (134)
        • Effective-Java (90)
        • Pragmatic Programmer (0)
        • CleanCode (11)
        • Clean Architecture (2)
        • Test-Driven Development (4)
        • Relational Data Modeling No.. (0)
        • Microservice Architecture (2)
        • 알고리즘 문제 해결 전략 (9)
        • Modern Java in Action (0)
        • Spring in Action (0)
        • DDD start (0)
        • Design Pattern (6)
        • 대규모 시스템 설계 (6)
        • JVM 밑바닥까지 파헤치기 (4)
      • Service Planning (2)
      • Side Project (5)
      • AI (0)
      • MATLAB & Math Concept & Pro.. (1)
      • Review (18)
      • Interview (2)
      • IT News (2)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

    • 깃
  • 공지사항

    • 한동안 포스팅은 어려울 것 같습니다. 🥲
    • N Tech Service 풀스택 신입 개발자가 되었습니다⋯
    • 취업 전 계획 재조정
    • 취업 전까지 공부 계획
    • 앞으로의 일정에 대하여..
  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.2
나죽못고나강뿐
[Clean Code] 9. 단위 테스트

개인정보

  • 티스토리 홈
  • 포럼
  • 로그인
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.