본문 바로가기
개발관련 서적 정리/Unit Testing

3. 단위 테스트 구조

by Backchus 2023. 2. 5.

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

AAA (Given - When - Then) 패턴 사용

  • 준비 구절에서는 테스트 대상 시스템(SUT, System Under Test)과 해당 의존성을 원하는 상태로 만든다.
  • 실행 구절에서는 SUT에서 메서드를 호출하고 준비된 의존성을 전달하며(출력이 있으면) 출력 값을 캡처한다.
  • 검증 구절에서는 결과를 검증한다. 결과는 반환 값이나 SUT와 협력자의 최종 상태, SUT가 협력자에 호출한 메서드 등으로 표시될 수 있다.

    각 구절은 얼마나 커야 하는가?

  • 준비 구절이 크다면 같은 테스트 클래스 내 비공개 메서드 또는 별도의 팩토리 클래스로 도출하는 것이 좋다.
  • 실행 궂걸이 한 줄 이상인 경우를 경계하라

    검증 구절에는 검증문이 얼마나 있어야 하는가

  • 검증 구절이 너무 커지는 것을 경계해야 한다.
  • SUT에서 반환된 객체 내에서 모든 속성을 검증하는 대신 객체 클래스 내에 동등성을 정의하는 것이 좋다.

3.2 테스트 간 테스트 실행 대상 객체의 재사용

테스트 생성자에서 초기화 코드 추출

public class CustomerTests {

    private Store store;

    private Customer sut;

    @BeforeEach
    public void init() {
        store = new Store();
        store.addInventory(Product.SHAMPOO, 10);
        sut = new Customer();
    }

    @Test
    public void purchase_succeeds_when_enough_inventory() {
        boolean success = sut.purchase(store, Product.SHAMPOO, 5);

        assertThat(success).isTrue();
        assertThat(5).isEqualTo(store.getInventory(Product.SHAMPOO));
    }

    @Test
    public void purchase_fails_when_not_enough_inventory() {
        boolean success = sut.purchase(store, Product.SHAMPOO, 15);

        assertThat(success).isFalse();
        assertThat(5).isEqualTo(store.getInventory(Product.SHAMPOO));
    }
}

2개의 테스트의 준비구절이 동일하기 때문에 따로 init메서드에 @BeforeEach 애노테이션을 선언하여 매 테스트 시작시 실행 대상 객체 및 SUT를 설정하도록 하여 중복되는 코드를 줄였다.

하지만 두 가지 중요한 단점이 있다.

  • 테스트 간 결합도가 높아진다.
  • 테스트 가독성이 떨어진다.

준비 로직이 별로 없더라도 테스트 메서드로 바로 옮기는 것이 좋다.

비공개 팩토리 메서드로 도출한 공통 초기화 코드

public class CustomerTests2 {

    @Test
    public void purchase_succeeds_when_enough_inventory() {
        Store store = createStoreWithInventory(Product.SHAMPOO, 10);
        Customer sut = CreateCustomer();

        boolean success = sut.purchase(store, Product.SHAMPOO, 5);

        assertThat(success).isTrue();
        assertThat(5).isEqualTo(store.getInventory(Product.SHAMPOO));
    }

    @Test
    public void purchase_fails_when_not_enough_inventory() {
        Store store = createStoreWithInventory(Product.SHAMPOO, 10);
        Customer sut = CreateCustomer();

        boolean success = sut.purchase(store, Product.SHAMPOO, 15);

        assertThat(success).isFalse();
        assertThat(10).isEqualTo(store.getInventory(Product.SHAMPOO));
    }

    private Store createStoreWithInventory(Product product, int quantity) {
        Store store = new Store();
        store.addInventory(product, quantity);
        return store;
    }

    private Customer CreateCustomer() {
        return new Customer();
    }
}

공통 초기화 코드를 비공개 메서드로 추출해 테스트 코드를 짧게 하면서, 동시에 테ㅡ트 진행 상황에 대한 전체 맥락을 유지할 수 있다. 게다가 비공개 메서드를 충분히 일반화하는 한 테스트가 서로 결합되지 않는다.

3.3 단위 테스트 명명법

  • 엄격한 명명 정책을 따르지 않는다. 복잡한 동작에 대한 높은 수준의 설명을 이러한 정책의 좁은 상자 안에 넣을 수 없다. 표현의 자유를 허용하자.
  • 문제 도메인에 익숙한 비개발자들에게 시나리오를 설명하는 것처럼 테스트 이름을 짓자. 도메인 전문가나 비즈니스 분석가가 좋은 예다.
  • 단어를 밑줄(_) 표시로 구분한다. 그러면 긴 이름에서 가독성을 향상시키는데 도움이 된다.

    테스트명 내 SUT의 메서드 이름을 포함하지 말라. 코드를 테스트하는 것이 아니라 애플리케이션 동작을 테스트하는 것이라는 점을 명심하자.

3.4 매개변수화된 테스트 리팩터링 하기

몇 가지 사실을 포괄하는 테스트

    @ParameterizedTest
    @CsvSource(value = {
        "-1,false",
        "0,false",
        "1,false",
        "2,false",
        "3,true"
    }, delimiter = ',')
    public void detects_an_invalid_delivery_data1(int daysFromNow, boolean expected) {
        // given
        DeliveryService sut = new DeliveryService();
        LocalDateTime deliveryDate = LocalDateTime.now().plusDays(daysFromNow);
        Delivery delivery = new Delivery(deliveryDate);

        // when
        boolean isValid = sut.isDeliveryValid(delivery);

        // then
        assertThat(isValid).isEqualTo(expected);
    }

매개변수화된 테스트를 사용하면 테스트 코드의 양을 크게 줄일 수 있지만, 비용이 발생한다. 이제 테스트 메서드가 나타내는 사실을 파악하기가 어려워졌다. 그리고 매개변수가 많을수록 더 어렵다. 절충안으로 긍정적인 테스트와 부정적인 테스트를 별도의 테스트로 나누고 각장 중요한 부분을 잘 설명하는 이름을 쓰면 좋다.

긍정적인 시나리오와 부정적인 시나리오를 검증하는 두 가지 테스트

    @ParameterizedTest
    @ValueSource(ints = {3, 4, 5})
    public void detects_an_valid_delivery_data2(int daysFromNow) {
        // given
        DeliveryService sut = new DeliveryService();
        LocalDateTime deliveryDate = LocalDateTime.now().plusDays(daysFromNow);
        Delivery delivery = new Delivery(deliveryDate);

        // when
        boolean isValid = sut.isDeliveryValid(delivery);

        // then
        assertThat(isValid).isTrue();
    }

    @ParameterizedTest
    @ValueSource(ints = {-1, 0, 1, 2})
    public void detects_an_invalid_delivery_data2(int daysFromNow) {
        // given
        DeliveryService sut = new DeliveryService();
        LocalDateTime deliveryDate = LocalDateTime.now().plusDays(daysFromNow);
        Delivery delivery = new Delivery(deliveryDate);

        // when
        boolean isValid = sut.isDeliveryValid(delivery);

        // then
        assertThat(isValid).isFalse();
    }

긍정과 부정시나리오를 각각 별도의 테스트로 진행하게되면 boolean매개변수를 제거하여 테스트케이스를 단순하게 할 수 있고 가독성이 좋아진다. 긍정적인 테스트 케이스와 부정적인 테스트 케이스 모두 각각 고유의 테스트 메서드로 나타내라.

매개변수화된 테스트를 위한 복잡한 데이터 생성

    @ParameterizedTest
    @MethodSource("data")
    public void can_detect_an_invalid_delivery_date(LocalDateTime deliveryDate, boolean expected) {
        // given
        DeliveryService sut = new DeliveryService();
        Delivery delivery = new Delivery(deliveryDate);

        // when
        boolean isValid = sut.isDeliveryValid(delivery);

        // then
        assertThat(isValid).isEqualTo(expected);
    }

    private static Stream<Arguments> data() {
        return Stream.of(
            Arguments.of(LocalDateTime.now().minusDays(1), false),
            Arguments.of(LocalDateTime.now(), false),
            Arguments.of(LocalDateTime.now().plusDays(1), false),
            Arguments.of(LocalDateTime.now().plusDays(2), false),
            Arguments.of(LocalDateTime.now().plusDays(3), true),
            Arguments.of(LocalDateTime.now().plusDays(4), true),
            Arguments.of(LocalDateTime.now().plusDays(5), true)
        );
    }

참고