Unit Testing : 3. 단위 테스트 구조

    들어가기 전

    이 글은 Unit Testing 3장 단위 테스트 구조를 공부하며 작성한 글입니다. 

     

    3. 단위 테스트 구조

    이 장에서는 단위 테스트를 잘 작성하는 방법에 대해서 정리한다. 

     


    3.1 단위 테스트를 구성하는 방법

    단위 테스트를 잘 구성하는 방법은 아주 중요하다. 단위 테스트를 잘 구성하면 코드의 주석이 없더라도 단위 테스트의 가독성이 올라간다. 

     

    3.1.1 Given / When / Then

    단위 테스트 코드를 작성할 때는 다음 세 가지 구절로 나누어서 코드를 작성하면 된다.

    • Given(준비) : 테스트 대상 시스템 (SUT : System Under Test)와 SUT에 필요한 의존성을 생성한다.
    • When(실행) : SUT에서 의존성을 전달하며 메서드를 호출한다. 즉, 검증 대상을 실행함.
    • Then(검증) : 실행 결과를 검증한다. 

    기본적으로 Given 절에서 가장 많은 코드를 작성하게 될 것이다. When 절은 한 줄의 실행 코드만 수행되어야 한다. 만약 When 절에서 두 줄 이상의 실행 코드가 필요하다면, 테스트 대상의 코드가 잘못 작성된 경우를 암시하므로 리팩토링이 필요하다. 관련된 내용은 뒷쪽에서 다시 한번 언급된다. 

     

    3.1.2 여러 개의 Given, When, Then 피하기

    단위 테스트 코드에서 여러 개의 Given, When, Then 절이 있는 경우 테스트를 분리해야하는 상황임을 암시한다. 특히나 검증 구절(Then)이 여러 개 있는 테스트 코드는 여러 개의 동작을 검증한다는 것을 의미한다. 하나의 동작만을 검증하게 된다면, 해당 테스트가 단위 테스트 코드로 존재할 수 있도록 한다. 단위 테스트 코드로 존재하면 빠르게 검증하고, 간단하고, 이해하기 쉬워진다.

    이런 이유 때문에 단위 테스트가 항상 여러 단계로 이루어진 테스트는 여러 개의 단위 테스트로 나누는 것이 더 좋다.

    3.1.3 테스트 내 If문 피하기

    If문이 있는 단위 테스트도 있다. If 문은 테스트가 한 번에 너무 많은 것을 검증한다는 표시다. 그러므로 이런 테스트도 반드시 여러 개의 테스트로 나누는 것이 맞다. 

     

    3.1.4 각 구절은 얼마나 커야하는가? (Given / When / Then)

    Given / When / Then에서 각 구절은 어느 정도 크기를 가지는 것이 이상적일까? 아래에서 나눠서 생각해보자.

     

    Given 절이 가장 큰 경우

    일반적으로 Given 절이 가장 크다. When + Then 절을 합친 것 이상으로 클 수 있다. Given 절의 적절한 리팩토링을 위해서 공통된 부분을 일반화하는 메서드를 테스트 클래스 내부에 작성하는 것이 좋다.

    실행 구절이 한 줄 이상인 경우

    실행 구절은 보통 코드 한 줄이다. 실행 구절이 두 줄 이상인 경우 SUT(System Under Test)의 테스트 대상 메서드에 문제가 있는 것을 의미할 수 있다. 아래 코드를 예시로 들 수 있다.

    @Test
    void purchaseSucceedsWhenEnoughInventory() {
        
        // given
        String item = "shampoo";
        Store store = new Store();
        store.addInventory(item, 10);
        Customer sut = new Customer();
        
        // when
        boolean success = sut.purchase(store, item, 10);
        store.removeInventory(item, 10);
    
        // then
        assertThat(success).isTrue();
        assertThat(store.getInventory(item)).isEqualTo(5);
    }

    위 코드의 문제점은 단일 작업을 수행하는데 두 개의 메서드 호출이 필요하다는 것이다. 이것은 클라이언트가 purchase() 메서드를 호출하면 반드시 store.removeInventory()까지 함께 호출하도록 강요한다. 만약 removeInventory()가 호출되지 않으면 모순이 발생한다. 

    이처럼 실행 절(when 절)에서 실행 코드가 여러 줄이 되는 것은 테스트 대상이 되는 클래스의 메서드가 잘못 작성되었을 가능성이 크다. 따라서 이런 부분은 리팩토링을 해야한다.

     

    3.1.5 검증 구절(then 절)에는 검증문이 얼마나 있어야 하는가? 

    단위 테스트는 하나의 동작을 테스트 한다. 그렇지만 하나의  동작의 결과는 여러 결과를 낼 수 있다. 하나의 테스트로 한 동작의 실행이 가져오는 여러 개의 결과를 평가하는 것은 좋은 방법이다. 그렇지만 then 절이 너무 커지는 것은 sut가 적절히 추상화가 안 되어있을 가능성을 알려준다. 만약 이런 경우라면 eqaul() 메서드를 구현하는 것으로 해결할 수 있다. 

     

    3.1.7 테스트 대상 시스템 구별하기

    SUT는 테스트에서 중요한 역할을 하는데, 테스트 하고자 하는 동작에 대해서 진입점을 제공할 수 있다. 그런데 이 동작에 진입하기 위해서는 많은 의존성 객체가 존재할 수 있다. When 절에서 의존성 객체와 SUT를 생성할 때, 너무 많은 의존성이 존재한다면 테스트 대상을 구별하는데 헷갈릴 수 있다. 이것을 방지하기 위해서 테스트 대상 객체의 이름을 sut라고 해주는 것도 좋은 방법이다. 

    // when
    boolean success = sut.purchase(store, item, 10);
    store.removeInventory(item, 10);

     


    3.3 테스트 간 테스트 픽스쳐 재사용

    테스트 픽스쳐는 각 테스트에서 의존성 객체로 주입할 녀석들을 의미한다. 이런 의존성 객체는 각 테스트가 실행되기 전에 알려진 고정 상태로 존재하기 때문에 동일한 결과를 생성한다. 따라서 픽스쳐라고 부른다. 테스트 코드에서는 테스트 텍스쳐를 재사용하면서 테스트 코드의 가독성을 올릴 수 있다.

     

    3.3.1 나쁜 테스트 픽스쳐 재사용 방법

    테스트 픽스쳐를 재사용하는 방법 중에 나쁜 방법은 다음 두 가지 의미를 가진다.

    • 테스트끼리 강하게 결합하도록 한다.
    • 텍스트 픽스쳐를 재활용하는 메서드의 의미전달이 명확하지 않다. 

    하나의 예시로는 아래 코드를 살펴볼 수 있다. 

    @Test
    void purchaseSucceedsWhenEnoughInventoryBadCase() {
    
        // given
        badFixture();
    
        // when
        boolean success = sut.purchase(store, item, 10);
        store.removeInventory(item, 10);
    
        // then
        assertThat(success).isTrue();
        assertThat(store.getInventory(item)).isEqualTo(5);
    }
    
    String item;
    Store store;
    Customer sut;
    
    void badFixture() {
        this.item = "ITEM";
        this.store = new Store();
        this.sut = new Customer();
        store.addInventory(item, 10);
    }

    예를 들어 위 코드를 이용해서 테스트 코드 내에서 재사용할 픽스쳐를 생성한다고 가정해보자. 이 때의 문제점은 다음과 같다.

    1. badFixture()라는 메서드를 호출했을 때, 무슨 일을 하는지 알 수 없다. 가독성이 나쁘다. 
    2. store.addInventory()의 값이 항상 고정되어있다. 

    가독성이 나쁘기 때문에 테스트 코드를 명확하게 이해하기 위해서는 항상 픽스쳐를 생성하는 코드를 다시 한번 살펴봐야한다.

    또한 store.addInventory(item, 10)으로 고정되어 있기 때문에 모든 테스트에서 이 부분의 영향을 받는다. 예를 들어서 어떤 테스트에서 15개를 사는 코드를 작성하고 싶다고 가정해보자. 이 때, 픽스쳐에는 10개 밖에 없기 때문에 문제가 발생한다. 따라서 픽스쳐 코드를 수정해야한다. 그런데 문제는 픽스쳐 코드의 수정이 나머지 전체 테스트 코드에 의도치 않게 영향을 미칠 수 있다는 점이다. 

    어떤 코드는 1개를 사는 테스트 코드가 있고, 기대값이 9개라고 생각해보자. 그런데 픽스쳐가 10 → 15로 늘었기 때문에 기대값도 9 → 14로 바뀌어야한다. 즉, 픽스쳐 코드를 하나 바꾸면서 전체 테스트 코드를 바꿔야 할 수도 있게 되는 것이다. 

     

    3.3.2 좋은 테스트 픽스쳐 생성 방법

    좋은 방법은 명확한 이름을 가진 테스트 픽스쳐 생성 메서드를 내부에 선언하는 것이다. 예를 들면 아래와 같이 작성할 수 있다. 

    void initializeItemAndStore(String itemName, int count) {
        this.item = itemName;
        this.store = new Store();
        this.sut = new Customer();
        store.addInventory(item, count);
    }

    위 코드를 살펴보면 이전에 있던 문제점이 해결된다.

    • 메서드 이름으로 어떤 값들이 초기화 되는지 알 수 있다. 
    • 픽스쳐를 셋팅하는데 필요한 값을 전달해준다. 즉, 각 테스트마다 서로 다른 형태의 픽스쳐를 생성해서 사용할 수 있게 된다. 

    이처럼 공통된 픽스쳐는 명시적인 이름과 적절한 인자값을 전달해서 추상화를 잘 해두는 것이 중요하다.

     

    3장 요약

    • 모든 단위 테스트는 Given / When / Then으로 나눠서 작성한다.
    • 테스트 코드에서 Given / When / Then 구절이 여러 개 있으면 테스트가 여러 동작 단위를 한 번에 검증한다는 표시다. 이 테스트가 단위 테스트라면 테스트 코드를 나눠야한다. 
    • 실행 구절(When)이 한 줄 이상이면 SUT의 API에 문제가 있다는 뜻이다. 클라이언트가 항상 이 일련의 메서드를 같이 호출해야한다는 의미인데, 잠재적으로 모순으로 이어질 수 있으므로 리팩토링이 필요하다.
    • SUT의 이름을 sut로 지정해 SUT를 테스트에서 구별하자.
    • 테스트 픽스쳐 초기화 코드는 팩토리 메서드를 도입해서 재사용하자. 재사용 시에는 가능한 경우 인자를 전달해서 생성하도록 한다. 이런 재사용은 테스트 간 결합도를 상당히 낮게 유지하고 가독성을 향상시킨다. 

    댓글

    Designed by JB FACTORY