ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 3. 단위 테스트 구조
    책/단위 테스트 2022. 7. 13. 23:13

    3. 1 단위 테스트를 구성하는 방법

    3.1.1 AAA 패턴 사용

    AAA 패턴이란 (Arrange, Act, Assert)해당 패턴을 사용하면 테스트를 준비, 실행, 검증 3부분으로 나눌 수 있습니다. 코드로 나타내면 다음과 같습니다.

    import domain.Calculator;
    import org.assertj.core.api.Assertions;
    import org.junit.jupiter.api.AfterEach;
    import org.junit.jupiter.api.BeforeEach;
    import org.junit.jupiter.api.Test;
    
    import static org.junit.jupiter.api.Assertions.*;
    
    class CalculatorTest {
    
        @Test
        public void sumOfTwoNumbers() {
            // 준비
            double first = 10;
            double second = 20;
            var calculator = new Calculator();
    
            // 실행
            double result = calculator.sum(first, second);
    
            // 검증
            assertEquals(30, result);
        }
    }

    AAA 패턴은 다음과 같이 스위트 내 모든 테스트가 단순하고 균일한 구조를 갖는데 도움이 됩니다.

    각 구절엔 다음과 같은 행동을 합니다.

    Arrange 구절

    • SUT와 해당 의존성을 원하는 상태로 만듭니다.
    • 코드에서 보면 Calculator를 인스턴스화 하고(SUT 생성) 해당 인스턴스에 메소드에 넣어줄 의존성(10, 20)을 생성해 주었습니다.

    Act 구절

    • SUT 에서 메서드를 호출하고 준비된 의존성을 전달하며 결과 값을 생성합니다.

    Assert 구절

    • 결과를 검증 합니다.
    • Given-When-Then 패턴
    • 해당 패턴과 AAA 는 사실상 동일한 패턴입니다. 단, 프로그래머가 아닌 사람들에게 Given-When-Then 패턴의 가동성이 더 좋습니다.

    대부분의 테스트 코드를 구성할 때 Arrange 단계부터 시작하는 것이 일반적입니다. 하지만 TDD 를 할 땐 어떤 기능이 어떻게 동작할지 충분히 알 수 없습니다. 이런 경우엔 검증단계 부터 작성하는 것도 고려해볼만 합니다.

    3.1.2 여러 개의 준비, 실행, 검증 구절 피하기

    실행(Act)이 하나면 테스트가 단위 테스트 범주에 있게끔 보장하고, 간단하고, 빠르며, 이해하기 쉽습니다. 실행(Act) 와 검증(Assert) 가 포함되어있으면 이를 리팩토링 하는것이 좋습니다.

    단, 통합 테스트에서는 실행 구절이 여러개 두는 것이 괜찮을 때도 있습니다. 하지만 해당 테스트가 너무 느리다면 여러 실행과 검증이 있는 테스트를 단일 테스트 여러개로 나누어 검증하는 최적화를 고려해봐야 합니다.

    3.1.3 테스트 if 문 피하기

    if 문은 테스트가 한번에 너무 많은 것을 검증한다는 표시입니다. 따라서 이런 테스트는 여러 단일 테스트로 나누는 것이 가독성 향후 유지보수 하는데 좋습니다.

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

    일반적으로 준비(Arrange) 구절이 가장 큽니다. 하지만 이것이 비 정상적이게 크다면 private 메서드 혹은 별도의 팩토리 클래스 로 도출 하는 것이 좋습니다.

    실행(Act) 구절은 보통 코드 한줄입니다. 만약 실행 구절이 두줄 이상인 경우 SUT 의 문제가 있을 수 있습니다. 다음 코드를 확인해보겠습니다.

        @Test
        public void purchaseSucceedsWhenEnoughInventory() {
    
            // 준비
            store = new Store();
            store.addInventory(Product.Shampoo, 10);
            sut = new Customer();
    
            // 실행
            boolean success = sut.purchase(store, Product.Shampoo, 5);
    
            // 검증
            assertTrue(success);
            assertEquals(5, store.getInventory(Product.Shampoo));
        }

    해당 코드의 실행 구절은 단일 메서드 호출이며 잘 설계된 클래스 API 임을 보여줍니다. 하지만 아래의 코드는 얘기가 다릅니다.

        @Test
        public void purchaseSucceedsWhenEnoughInventory() {
            // 준비
            store = new Store();
            store.addInventory(Product.Shampoo, 10);
            sut = new Customer();
    
            // 실행
            boolean success = sut.purchase(store, Product.Shampoo, 5);
            store.removeInventory(success, Product.Shampoo, 5);
    
            // 검증
            assertTrue(success);
            assertEquals(5, store.getInventory(Product.Shampoo));
        }

    해당 코드를 보면 테스트 자체는 문제가 없습니다. 하지만 테스트가 SUT의 단일 메서드를 올바르게 작동시키기 위해서는 추가적인 메서드 호출이 강요됩니다.

    만약 아래 메서드(removeInventory)를 호출하지 않으면 어떻게 될까요? 고객은 상품을 구매했지만 매장의 재고 수량은 줄어들지 않습니다.

    이런 잠재적 모순으로 부터 코드를 보호하는 행위를 캡슐화 라고 합니다.

    코드 캡슐화는 항상 지켜져야 합니다. 첫 예시처럼 purchase 메서드의 한부분으로 고객이 매입한 재고를 제거하고, 클라이언트 코드에 의존하지 않아야 합니다.

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

    단위 테스트의 단위는 동작의 단위지 코드의 단위가 아닙니다. 단일 동작 단위는 여러 결과를 낼 수 있고 하나의 테스트로 모든 결과를 평가하는것이 좋습니다.

    그렇다고 검증문이 너무 커지면 좋지않습니다. 다음과 같은 추상화를 사용하면 이를 어느정도 해결할 수 있습니다.

            asssertEqual(member.getId, 1);
            asssertEqual(member.getName, "felix");
            asssertEqual(member.getLocale, "ko_KR");
    
    	---------- 추상화 ----------------
            assertEqual(member, findMember)

    3.1.6 종료 단계는 어떤가

    AAA 이후에 4번째 구절로 종료 구절을 따로 구분하기도 합니다.

    테스트에 의해 작성된 파일을 지우거나 DB 연결을 종료하고자 할 때 해당 구절을 사용하면 됩니다.

    일반적인 단위테스트는 종료 구절이 필요없습니다.

    3.1.8 준비, 실행, 검증 주석 제거하기

    빈줄로 구절을 구분하면 대부분의 단위 테스트에서 효과적입니다. 단, 대규모 테스트에서는 준비 단계에 빈 줄을추가해 설정단계를 구분 할 수도 있습니다.

    • AAA 패턴을 따르고 준비 및 검증 구절에 빈 줄을 추가하지 않아도 되는 테스트라면 구절 주석들을 제거합니다.
    • 그렇지 않다면 구절 주석을 유지하는것이 좋습니다.

    3.2 xUnit 테스트 프레임워크 살펴보기

    이번 포스팅에선 JUnit을 살펴보기 때문에 넘어가도록 하겠습니다.

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

    테스트 픽스처SUT 로 전달되는 인수 를 말합니다. 각 테스트 실행 전에 알려진 고정 상태로 유지하기 때문에 동일한 결과를 생성합니다.

    테스트 픽스처를 재사용하는 방법은 여러가지가 있습니다.

    1. 테스트 생성자에 픽스처를 초기화하기
    public class CustomerTest {
    
        private final Store store;
        private final Customer sut;
    
        public CustomerTest() {
            store = new Store();
            store.addInventory(Product.Shampoo, 10);
            sut = new Customer();
        }
    
        @Test
        public void purchaseSucceedsWhenEnoughInventory() {
            boolean success = sut.purchase(store, Product.Shampoo, 5);
            assertTrue(success);
            assertEquals(5, store.getInventory(Product.Shampoo));
        }
    
        @Test
        public void purchaseFailsWhenNotEnoughInventory() {
            boolean success = sut.purchase(store, Product.Shampoo, 15);
            assertFalse(success);
            assertEquals(10, store.getInventory(Product.Shampoo));
        }
    }

    이 방법을 사용하면 테스트 코드의 양을 크게 줄일 수 있으며 테스트 에서 테스트 픽스처 구성을 대부분 제거할 수 있습니다.

    하지만 다음과 같은 단점이 있습니다.

    • 테스트 간 결합도가 높아집니다.
      • 만약 테스트의 준비 로직을 수정하면 클래스의 모든 테스트에 영향을 미칩니다.
      • 테스트는 수정되어도 다른 테스트에 영향을 주어서는 안됩니다.
      • 테스트 클래스에 공유 상태를 두지 말아야 합니다.
    • 생성자 사용으로 인해 테스트의 가독성을 떨어뜨립니다.
      • 테스트를 실행할 때 해당 메서드 뿐 아니라 생성자도 별도로 확인을 해야합니다.

    3.3.3 더 나은 테스터 픽스처 재 사용법

    테스트 클래스에 다음과 같이 private 팩토리 메서드를 둘 수 있습니다.

    public class CustomerStoreTest {
    
        @Test
        public void purchaseSucceedsWhenEnoughInventory() {
            Store store = createStoreWithInventory(Product.Shampoo, 5);
            Customer sut = createCustomer();
            boolean success = sut.purchase(store, Product.Shampoo, 5);
            assertTrue(success);
            assertEquals(5, store.getInventory(Product.Shampoo));
        }
    
        @Test
        public void purchaseFailsWhenNotEnoughInventory() {
            Store store = createStoreWithInventory(Product.Shampoo, 5);
            Customer sut = createCustomer();
            boolean success = sut.purchase(store, Product.Shampoo, 15);
            assertFalse(success);
            assertEquals(10, store.getInventory(Product.Shampoo));
        }
    
        private Store createStoreWithInventory(Product product, int quantity) {
            Store store = new Store();
            store.addInventory(product, quantity);
            return store;
        }
    
        private static Customer createCustomer() {
            return new Customer();
        }
    }

    이전과 달리 다른 테스트에서도 사용할 수 있도록 수량과 품목을 파라미터화 했습니다.

    테스트 픽스처 재 사용 규칙에는 한가지 예외가 있습니다. 대부분의 테스트에 사용되는 경우 생성자에 픽스처를 인스턴스화 할 수 도 있다는 것입니다. DB와의 연동 테스트가 여기에 해당합니다.

    3.4 단위 테스트 명명법

    주로 [테스트 대상 메서드]_[시나리오]_[예상 결과] 포맷으로 테스트를 명명합니다. 하지만 이는 동작 대신 구현 세부 사항에 집중하게끔 부추기기 때문에 도움이 되지 않습니다.

    3.4.1 단위 테스트 명명 지침

    표현력 있고 읽기 쉬운 테스트 이름을 짓기 위해선 다음과 같은 지침을 따르는것이 좋습니다.

    1. 엄격한 명명 정책을 따르지 않습니다.
    2. 문제 도메인에 익숙한 비개발자들에게 시나리오를 설명하는 것처럼 테스트 이름을 짓습니다.
    3. 단어를 밑줄(_) 표시로 구분합니다.
    4. 테스트 이름에 SUT의 메서드 이름을 포함하지 말아야합니다.

    참고: 단위 테스트에서 단위는 동작이지, 클래스 단위가 아닙니다. 따라서 단위가 여러 클래스에 걸쳐있을 수 있습니다. 그래서 어딘가에서 시작을 해야 하므로 [클래스명]Test 에서 클래스는 동작 단위로 검증할 수 있는 진입점이라고 생각하는것이 좋습니다.

    3.4.2 예제: 지침에 따른 테스트 이름 변경

        @Test
        public void isDeliveryValid_InvalidDate_ReturnsFalse() {
            DeliveryService sut = new DeliveryService();
            LocalDateTime pastDate = LocalDateTime.now().minusDays(1L);
            Delivery delivery = new Delivery(pastDate);
    
            boolean isValid = sut.isDeliveryValid(delivery);
            assertFalse(isValid);
        }

    해당 테스트는 잘못된 날짜의 배송을 올바르게 식별하는지 검증합니다. 하지만 해당 메서드 명보단 이런 이름이 더 좋을거 같습니다. delivery_with_invalid_date_should_be_considered_invalid() 하지만 이 보단 과거의 날짜는 안된다고 하는 의미를 담는 것이 더 좋을 거 같습니다.

    delivery_with_past_date_should_be_considered_invalid()

    좋지만 너무 장황합니다. 따라서 considered 라는 단어는 빼도 의미가 퇴색될거 같지 않아 이를 제거합니다.

    delivery_with_past_date_should_be_invalid()

    should be 는 안티 패턴입니다. 테스트는 동작 단위에 대해 단순하고 원자적인 사실입니다. 사실을 서술할 때는 소망이나 욕망이 들어가서는 안됩니다.

    delivery_with_past_date_is_invalid()

    아주 좋습니다. 하지만 기초 영문법은 지켜야 합니다.

    delivery_with_a_past_date_is_invalid()

    기획팀에서 가장 빠른 배송일이 오늘로부터 이틀 후가 되도록 작동하는 배송 기능을 구현해달라는 요청을 받았습니다.

    기능 구현은 완료했고 이제 테스트를 짜보겠습니다. 만약 고객이 오늘 주문을 했다면 배송가능 날짜가 당일로 잡힐때, 하루 뒤로 잡힐 때, 2일 부로 잡힐때(맞습니다.) 를 테스트 해보려고 합니다.

    이를 테스트 하기 위해서 각 테스트 메서드를 만드는 것은 매우 비 효율적입니다.

    배송 가능 날짜의 차이점만 있기 때문입니다.

    테스트 코드의 양을 줄이고자 테스트를 하나로 묶어보겠습니다.

        @ParameterizedTest
        @MethodSource("isInvalid_delivery_date")
        @DisplayName("가장 빠른 배송일이 오늘로부터 이틀 후가 됩니다.")
        public void can_detect_an_invalid_delivery_date(LocalDateTime daysFromNow, boolean expected) {
            DeliveryService sut = new DeliveryService();
            Delivery delivery = new Delivery(daysFromNow);
            assertEquals(expected, sut.isDeliveryValid(delivery));
        }
    
        private static Stream<Arguments> isInvalid_delivery_date() { // argument source method
            return Stream.of(
                Arguments.of(LocalDateTime.now().minusDays(1L), false),
                Arguments.of(LocalDateTime.now(), false),
                Arguments.of(LocalDateTime.now().plusDays(1L), false),
                Arguments.of(LocalDateTime.now().plusDays(2L), true)
            );
        }

    이렇게 매개변수화된 테스트를 사용하면 테스트 코드의 양을 줄일 수 있지만, 테스트 메서드가 나타내는 사실을 파악하기 매우 어려워 졌습니다.

    이를 해결 하기 위해선 긍정적인 테스트 케이스는 고유한 테스트로 도출하고, 가장 중요한 부분을 잘 설명하는 이름을 쓰면 좋습니다.

        @ParameterizedTest
        @MethodSource("isInvalid_delivery_date")
        public void detects_an_invalid_delivery_date(LocalDateTime daysFromNow) {
    
        }
    
        @Test
        private void the_soonest_delivery_date_is_two_days_from_now() {
            //
            );
        }

    테스트 코드 양과 그 코드의 가독성은 서로 상충 됩니다.

    입력 매개변수만으로 테스트 케이스를 판단할 수 있다면 긍정적인 테스트 케이스와 부정적인 테스트 케이스 모두 하나의 메서드로 두는 것이 좋습니다.

    3.6 검증문 라이브러리를 사용한 테스트 가독성 향상

    테스트 가독성을 높이기 위한 방법으로 검증문 라이브러리를 사용하는것을 들 수 있습니다.

        @Test
        public void sum_of_two_number() {
            var calculator = new Calculator();
            double result = calculator.sum(10, 20);
            assertEquals(30, result);
        }
       @Test
        public void sum_of_two_number() {
            var sut = new Calculator();
            double result = sut.sum(10, 20);
            Assertions.assertThat(result).isEqualTo(30); // assertJ 활용
        }

    프로그래머도 사람이기 때문에 좀 더 구어적인 표현으로 코드를 구성한다면 가독성이 더 좋아집니다.

    참고

    ' > 단위 테스트' 카테고리의 다른 글

    Classicist TDD vs Mockist TDD  (0) 2022.07.25
    단위 테스트 1장  (0) 2022.06.26

    댓글