쌩로그

Practical Testing 실용적인 테스트 가이드 - Section 07. 더 나은 테스트를 작성하기 위한 구체적 조언 본문

Spring/Test

Practical Testing 실용적인 테스트 가이드 - Section 07. 더 나은 테스트를 작성하기 위한 구체적 조언

.쌩수. 2024. 2. 6. 06:03
반응형

목록

  1. 포스팅 개요
  2. 본론
      2-1. 한 문단에 한 주제!
      2-2. 완벽하게 제어하기
      2-3. 테스트 환경의 독립성을 보장하자
      2-4. 테스트 간 독립성을 보장하자
      2-5. 한 눈에 들어오는 Test Fixture 구성하기
      2-6. Test Fixture 클렌징
      2-7. @ParameterizedTest
      2-8. @DynamicTest
      2-9. 테스트 수핻오 비용이다. 환경 통합하기
      2-10. Q. private 메서드의 테스트는 어떻게 하나요?
      2-11. Q. 테스트에서만 필요한 메서드가 생겼는데 프로덕션 코드에서는 필요 없다면?
  3. 요약

1. 포스팅 개요

인프런에서 박우빈님의 Practical Testing: 실용적인 테스트 가이드 강의 섹션 7 더 나은 테스트를 작성하기 위한 구체적 조언을 학습하며 정리한 포스팅이다.

이번 섹션에서는 작은 단위의 테스트를 하면서 중요한 조언이 될 수 있는 디테일한 부분들을 살펴본다.

| 참고 이전 포스팅

2. 본론

2-1. 한 문단에 한 주제!

테스트는 하나의 문서로서의 기능을 한다.
테스트 코드는 글쓰기의 관점에서 각각 하나하나의 테스트가 한 문단이라고 할 수 있다.
따라서 하나의 테스트는 하나의 주제만을 가져야 한다.

예를 들어 이전에 작성했던 제품의 타입에 대한 클래스를 작성햇었다.\

@Getter
@RequiredArgsConstructor
public enum ProductType {   // 제품 타입

    HANDMADE("제조 음료"),
    BOTTLE("병 음료"),
    BAKERY("베이커리");

    private final String text;

    public static boolean containsStockType(ProductType type) {
        return List.of(BOTTLE, BAKERY).contains(type);
    }
}

그런데 만약에 이 클래스에 대한 테스트로 다음과 같이 작성했다고 하자.

if와 for문을 통해서 타입에 따라 결과가 잘 나오는지를 보는 테스트인데,
if 문을 통해서 테스트가 분기처리 되고 있는 것 자체가 이미 하나의 테스트에 두 가지 이상의 주제를 테스트하고 있는 것이다.

그리고 반복문을 읽는 사람도 "이 테스트가 뭘 하려는 거지?"하며 생각을 해야한다.

따라서 이런 테스트는 지양하는 것이 좋다.
만약 확장이 필요하다면 이후 알려주는 Parameterize 테스트가 좋다.

한 가지 테스트에는 한 가지 목적의 검증만 수행을 하는 것이 좋다.

2-2. 완벽하게 제어하기

테스트를 하기 위한 환경을 조성할 때 모든 조건을 완벽하게 제어할 수 있어야 한다.

위의 코드는 강의 초반에 나왔던 테스트인데,
위 쪽 테스트는 현재 시간이라는 제어할 수 없는 코드를 작성해서 현재 시간 정보를 기반으로 주문 시간을 벗어나는가 벗어나지 않는가에 대한 테스트였다.
현재 시간이라는 값을 상위 계층으로 분리해서 테스트가 가능한 구조로 만들었었다.
그래서 테스트에 대한 상황을 제어할 수 있었다.

이처럼 즉 테스트를 할 때 테스트 환경을 조성하는데, 과연 "테스트 환경을 완벽하게 제어할 수 있는가?" 질문을 계속하면서 구성을 하는 것이 좋다.

Mock으로 구성하는 부분도 이와 같다.

그리고 현재 시간 값은 LocalDataTime.now()보다는 고정된 값을 가지고 가는 것이 좋다.

테스트 환경을 완벽하게 제어할 수 있어야 한다.

2-3. 테스트 환경의 독립성을 보장하자

given 절을 통해 테스트 행위를 하기 위한 준비 과정들을 만들었었다.
혹은 API들을 끌어다가 사용해서 테스트 간 결합도가 생기는 케이스가 있을 수 있다.

이런 부분에 대해서 독립성을 보장해야 한다는 것이다.

다음과 같은 코드가 있을 때

@Test
@DisplayName("재고가 부족한 상품으로 주문을 생성하려는 경우 예외가 발생한다.")
void createOrderWithNoStock() {
   // given
   LocalDateTime registeredDateTime = LocalDateTime.now();

   Product product1 = createProduct(BOTTLE, "001", 1000);
   Product product2 = createProduct(BAKERY, "002", 3000);
   Product product3 = createProduct(HANDMADE, "003", 5000);
   productRepository.saveAll(List.of(product1, product2, product3));

   Stock stock1 = Stock.create("001",2);
   Stock stock2 = Stock.create("002",2);
   stock1.deductQuantity(1); // todo
   stockRepository.saveAll(List.of(stock1, stock2));

   OrderCreateRequest request = OrderCreateRequest.builder()
            .productNumbers(List.of("001","001","002","003"))
            .build();

   // when // then
   assertThatThrownBy(() -> orderService.createOrder(request.toServiceRequest(), registeredDateTime))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessage("재고가 부족한 상품이 있습니다.");
}

주석으로 todo가 되어있는 부분이 있다.

그리고 then절에서는 createOrder를 보면 알 수 있듯이
현재 테스트를 하고자하는 것은 createOrder()이다.

그런데 todo를 보면 deductQuantity()라는 재고 차감 메서드를 수행하고 있다.
즉 주문을 생성하고자 하는 행위에 재고 차감이라는 다른 행위를 끌어옴으로써 두 가지의 케이스가 혼합되어있는 것이다.
만약 deductQuantity 메서드의 파라미터가 1이지만, 3으로 했다면 차감할 재고가 없어서 테스트가 실패한다.

이 떄 두 가지 문제가 발생한다.

테스트하는 환경에서 맥락을 이해하는 도중 deductQuantity() 메서드 부분을 생각을 해야한다.
이 메서드를 이해해야하는 허들이 생기는 것이다.
"처음에 2개를 생성했는데 여기서 1개를 차감시켰고, 그럼 1개가 남겠네?" 라는 논리적인 사고의 과정이 들어가야 한다.

따라서 처음 얘기했던 한 문단에 한 주제를 테스트하는 것과 마찬가지로 분기문과 반복문을 애기할 때 논리구조가 들어간다는 것과 똑같은 내용이다. 테스트 맥락을 한 번 더 이해해야하는 허들이 생긴 것이다.

두 번째 문제는 테스트가 실패했는데, 테스트가 터진 부분이 given절인 deductQuantity()에서 터졌다.

테스트가 실패하더라도 실패하는 부분은 when, then쪽에서 실패해야하는데, given절에서 터진 것이다.
즉 주제와 맞지 않는 부분에서 테스트를 실패한 것이다.

더욱 복잡한 시스템에서는 테스트가 깨졌는데 왜 실패했는지 유추하기 어려운 포인트가 될 수 있다.
따라서 이러한 문제점이 있기 때문에 테스트 환경을 조성할 때는 최대한 생성자 기반으로 구성하는 것이 좋다.

팩토리 메서드도 지양한다.
테스트 코드에서는 순수한 빌더나 순수한 생성자로만 테스트 given절을 구성하는 것이 좋다.

최대한 독립적인 환경을 만들어서 테스트 하는 것이 좋다.

2-4. 테스트 간 독립성을 보장하자

재고 수량이 외부에서 제공된 수량보다 작은가에 대한 테스트인데,
두 가지 이상의 테스트가 하나의 자원을 공유하는 것에 대한 부분이다.

위의 코드 그림에서 static final로 선언된 부분이 있는데,
아래의 재고차감 테스트에서는 deductQuantity() 메서드를 통해서 stock의 값을 변경하고 있다.

그리고 이렇게 변경된 값은 다른 테스트에서 어떻게 작동할지 알 수 없다.
또한 위의 테스트 같은 경우 테스트 간 순서에 따라 성공 실패가 갈릴 수도 있다.

테스트 간에는 순서가 없어야 한다. 테스트는 순서와 무관해야 한다.
즉, A 테스트가 수행된 이후에 B 테스트가 수행되어야 성공을 한다 와 같은 개념자체가 없어야 되고 각각 독립적으로 언제 수행되든 항상 같은 결과를 내야 된다.

따라서 공유자원 같은 것은 사용하지 않아야 한다.

2-5. 한 눈에 들어오는 Test Fixture 구성하기

Test Fixture란?

  • Fixture : 고정물, 고정되어 있는 물체
  • 테스트를 위해 원하는 상태로 고정시킨 일련의 객체

given 절에서 생성했던 모든 객체를 의미한다.
즉, 내가 어떤 테스트 목적, 테스트 환경을 위해서 원하는 상태 값으로 고정시킨 객체들을 Test Fixture이라고 한다.
주로 given 절에서 만나게 된다.

@BeforeAll

  • 전체 테스트 코드들 실행 전에 지정한 작업을 수행한다.

@BeforeEach

  • 매 테스트 전에 지정한 작업을 수행한다.

@AfterEach

  • 하나의 테스트가 끝날 때마다 지정한 작업을 수행한다.

@AfterAll

  • 전체 테스트 코드가 끝나고 지정한 작업을 수행한다.

테스트할 때마다 given 절에서 작성했던 기본 데이터들이 있었는데,
하나의 클레스에서 여러 테스트들을 작성할 때 기본데이터가 겹치는 경우가 많았다.

이런 겹치는 기본데이터들을 테스트 코드 이전에 셋팅을 해놓고, 중복을 제거하게 해서 사용할 수도 있...지만, 결국 공유 변수 혹은 공유 객체가 되기 때문에 테스트간 결합도가 생기게 된다.

따라서 이 픽스처들을 수정하거나 하는 경우 모든 테스트에 공통적으로 영향을 주므로 지양하는 것이 좋다.

그리고 또 다른 문제는

만약에 구성을 했는데 테스트 클래스가 엄청 길어졌다.
가장 아래쪽인 테스트에서 테스트를 읽으려고 하려하는데, 아래 쪽 테스트는 별 내용이 없다.
셋업에 대한 코드는 위쪽에서 더욱 많을 때는 계속 왔다 갔다 하면서 확인해야 한다.

테스트 코드는 문서로서의 테스트 기능을 되게 많이 한다. 그러나 이렇게 되면
문서로서의 역할을 하기가 어렵다.
필요한 정보들이 파편화되어 있다는 단점이 있다.

그렇다면 셋업 혹은 BeforeEach 같은 경우 언제 쓰냐했을 때,
"각 테스트 입장에서 테스트를 아예 몰라도 테스트 내용을 이해하는데 문제가 없는가?" 라는 고민을 해야한다. 또한 "테스트를 수정해도 모든 테스트에 영향을 주지 않는가?" 고민 했을 때
문제가 없다면 기본 세팅 코드는 BeforeEach 와 같은 곳에 있어도 된다(고 생각을 한다고 하신 우빈님)

예들 들어 Product 엔티티와 관계를 가지는 다른 엔티티가 있을 때 Product를 생성하기 위해서는 필요하지만, Product 테스트를 할 때는 이 엔티티가 전혀 필요가 없고 몰라도 된다면 위의 두 고민에 만족하는 경우다.

given절이 길더라도 문서로서의 테스트를 생각하면서 코드를 작성하면 좋다.

  • 강의 초반에 given절 대신 data.sql을 사용했었다.

그런데 이것 또한 지양해야 한다.
왜냐하면 테스트를 보는데, given절이 없다. 알고봤더니 data.sql을 통해서 데이터가 insert되고 있었다.
따라서 데이터가 파편화되어있기 때문에 무엇을 테스트하는지 파악하기 힘들다.
또한 프로젝트가 커질 수록 data.sql의 규모는 커진다.(관리 포인트가 하나 더 늘어난다...)

  • 테스트 클래스마다 빌더 클래스들을 만들어서 사용했었다.

빌더로 클래스를 생성하는 경우, 파라미터에 테스트 클래스 내에서 필요한 것들만 남겨놓으면 좋다.
예를 들어 상품이름이 중요치 않다면, 파라미터에선 제거하고, 빌더체인에는 정적인 데이터를 담아놓는다.
테스트 클래스마다 필요한 필드만 명시하도록 적용하는 것이 좋다.

  • 빌더를 클래스마다 만들었는데, 이것이 귀찮다 따라서 한 곳에 모아서 쓸 순 없는가?

테스트 패키지 전체에서 사용하는 하나의 추상 클래스를 만들어서 거기에 모든 fixture 빌더들을 가져와서 사용하는 방법도 있지만, 추천하지 않는다.
왜냐하면 테스트마다 필요한 값이 매번 다르기 때문에 파라미터도 달라진다. 즉 새로운 빌더가 계속 생긴다.
(실무에선 더 그렇다.)

(우빈님은 이렇게 해봤었는데, 더욱 힘들었다고 한다.)

테스트 클래스마다 필요한 파라미터만 뽑아서 사용할 수 있도록 구성하는 것이 좋다고 한다.
(근데 우빈님도 이것이 귀찮다고 한다.)
클래스마다 어떤 필드가 필요할지 고민하고 빌더 메서드를 작성하는 것이 귀찮다고 하는데,

코틀린을 사용하면 이런 고민이 없어진다고한다...!!??)

각 테스트마다 독립적으로 작성하여 문서로서의 기능이 잘 잘동하도록 하고, 중복되는 코드가 있더라도 각 테스트 클래스마다 필요한 데이터를 받도록 일일이 작성해주는 것이 좋다.

2-6. Test Fixture 클렌징

deleteAll()deleteAllInBatch()의 차이를 알아보자.

참고

DB에서 테이블을 지울 때, 만약 A 테이블을 참조하고 있는 B 테이블이 있다면 참조하고 있는 B 테이블을 먼저 지우고 그 다음 순으로 A 테이블을 지워야 한다.

deleteAllInBatch()는 테이블 전체를 bulk성으로 날릴 수 있는 메서드다.
우빈님은 deleteAll()보단 deleteAllInBatch()를 더 즐겨 사용한다고 하는데, 왜 그럴까?

deleteAll()delete 쿼리를 날리기 전 select 쿼리로 지울 데이터에 대한 테이블을 조회한다.
그리고 delete 쿼리를 날릴 때도 한 번에 삭제하는 것이 아니라, 건건이 삭제한다.

두 메서드의 결과를 살펴보자.

@AfterEach
void tearDown() {
    orderProductRepository.deleteAllInBatch();
    productRepository.deleteAllInBatch();
    orderRepository.deleteAllInBatch();

//  deleteAll
        // orderProductRepository.deleteAll();
        // productRepository.deleteAll();
        // orderRepository.deleteAll();


    stockRepository.deleteAllInBatch();
}

@Test
@DisplayName("주문 번호 리스트를 받아 주문을 생성한다.")
void createOrder() {
    // given
    LocalDateTime registeredDateTime = LocalDateTime.now();

    Product product1 = createProduct(HANDMADE, "001", 1000);
    Product product2 = createProduct(HANDMADE, "002", 3000);
    Product product3 = createProduct(HANDMADE, "003", 5000);

    productRepository.saveAll(List.of(product1, product2, product3));

    OrderCreateRequest request = OrderCreateRequest.builder()
            .productNumbers(List.of("001", "002"))
            .build();

    // when
    OrderResponse orderResponse = orderService.createOrder(request.toServiceRequest(), registeredDateTime);

    // then
    assertThat(orderResponse.getId()).isNotNull();
    assertThat(orderResponse)
            .extracting("registeredDateTime", "totalPrice")
            .contains(registeredDateTime, 4000);

    assertThat(orderResponse.getProducts()).hasSize(2)
            .extracting("productNumber", "price")
            .containsExactlyInAnyOrder(
                    tuple("001", 1000),
                    tuple("002", 3000)
            );
}

참고로 테이블 구조는
Order와 Product의 관계가 다대다이기 때문에, 중간에 연결테이블로 OrderProduct 라는 테이블을 두었다.

먼저 deletAllInBatch() 메서드를 보면 다음과 같이 처리하는 게 깔끔하다.

이제 deleteAll()을 보자.
못 보던 select가 생겼다.

실행된 쿼리는 다음과 같다.

Hibernate:
    select
        op1_0.id,
        op1_0.created_date_time,
        op1_0.modified_date_time,
        op1_0.order_id,
        op1_0.product_id
    from
        order_product op1_0
Hibernate:
    delete
    from
        order_product
    where
        id=?
Hibernate:
    delete
    from
        order_product
    where
        id=?
Hibernate:
    select
        p1_0.id,
        p1_0.created_date_time,
        p1_0.modified_date_time,
        p1_0.name,
        p1_0.price,
        p1_0.product_number,
        p1_0.selling_status,
        p1_0.type
    from
        product p1_0
Hibernate:
    delete
    from
        product
    where
        id=?
Hibernate:
    delete
    from
        product
    where
        id=?
Hibernate:
    delete
    from
        product
    where
        id=?
Hibernate:
    select
        o1_0.id,
        o1_0.created_date_time,
        o1_0.modified_date_time,
        o1_0.order_status,
        o1_0.registered_date_time,
        o1_0.total_price
    from
        orders o1_0
Hibernate:
    select
        op1_0.order_id,
        op1_0.id,
        op1_0.created_date_time,
        op1_0.modified_date_time,
        op1_0.product_id
    from
        order_product op1_0
    where
        op1_0.order_id=?
Hibernate:
    delete
    from
        orders
    where
        id=?
Hibernate:
    delete
    from
        stock

이처럼 select를 하고 건건이 지웠다는 것을 확인할 수 있다.
deleteAll()을 사용하면 객체가 참조된 것도 일일이 찾아서 지우기 떄문에 순서는 상관없다.

그리고 deleteAll()을 내부적으로 들어가면 SimpleJpaRepository.class에서 찾을 수 있는데,
아래 그림으로 나온 코드처럼 먼저 findAll()을 통해서 전체 데이터를 다 땡긴다.
그리고 while문을 돌면서 일일이 지우고 있다는 것을 확인할 수 있다.

성능 차이가 많이 날 수 있다는 것을 알 수 있다.

따라서 순서를 잘 고려하여 deleteAllInBatch()를 사용하는 것이 더욱 좋다.

2-7. @ParameterizedTest

이전에 테스트 코드 내에서 if-else와 같은 분기처리 되는 부분은 읽는 사람의 생각을 요하는 코드를 작성하는 것을 지양해야 한다고 했었고, 이런 부분은 여러 가지 케이스이기 때문에 테스트 코드를 나누는게 바람직한지 고민하는 것이 필요하다고 했었다.

그런데 그런 케이스말고 단순히 딱 하나의 테스트 케이스이지만,
값을 여러 개로 바꿔보면서 테스트를 하고 싶을 때 사용하는 것이 @ParameterizedTest이다.

값이나 환경에 대한 데이터들을 바꿔가면서 테스트를 여러 번 반복하고 싶을 때 사용하는 JUnit 어노테이션이다.

만약 테스트를 다음과 같이 작성했을 때,

@Test
@DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
void Test() {
    // given
    ProductType givenType1 = ProductType.HANDMADE;
    ProductType givenType2 = ProductType.BOTTLE;
    ProductType givenType3 = ProductType.BAKERY;

    // when
    boolean result1 = ProductType.containsStockType(givenType1);
    boolean result2 = ProductType.containsStockType(givenType2);
    boolean result3 = ProductType.containsStockType(givenType3);

    // then
    assertThat(result1).isFalse();
    assertThat(result2).isTrue();
    assertThat(result3).isTrue();

}

ProductType에 따른 체크 메서드를 테스트해보고 싶다고 했을 때
이 경우는 한 가지 케이스는 맞을 것이다.
그러나 값을 바꿔가면서 모든 Enum 값에 대한 테스트를 해보고 싶은 것이다.

이럴 때 활용할 수 있는 것이 @ParameterizedTest이다.

그런데 지금 위와 같이 작성하면 케이스가 많아보이고, 어떤 데이터에 대한 결과가 어떤 것인지 알아보기 힘들다.

따라서 다음과 같이 작성할 수 있다.

@DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
@CsvSource({"HANDMADE, false","BOTTLE,true","BAKERY,true"})
@ParameterizedTest
void containsStockTypeTest4(ProductType productType, boolean expected) {
    // when
    boolean result = ProductType.containsStockType(productType);

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

@CsvSource와 같이 source를 주는 방법은 다양하다.
이처럼 @CsvSource에 있는 테스트의 파라미터에 각각 들어가게 되면서 테스트를 진행하게 된다.

결과는 다음처럼 나온다.

source 같은 경우는 주는 방법이 다양한데 다음과 같이도 할 수 있다.

@DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
@CsvSource({"HANDMADE, false","BOTTLE,true","BAKERY,true"})
@ParameterizedTest
void containsStockTypeTest4(ProductType productType, boolean expected) {
    // when
    boolean result = ProductType.containsStockType(productType);

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

private static Stream<Arguments> provideProductTypesForCheckingStockType() {
    return Stream.of(
            Arguments.of(ProductType.HANDMADE, false),
            Arguments.of(ProductType.BOTTLE, true),
            Arguments.of(ProductType.BAKERY, true)
    );
}

@DisplayName("상품 타입이 재고 관련 타입인지를 체크한다.")
@MethodSource("provideProductTypesForCheckingStockType")
@ParameterizedTest
void containsStockTypeTest5(ProductType productType, boolean expected) {
    // when
    boolean result = ProductType.containsStockType(productType);

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

결과는 다음과 같다.

이처럼 메서드 이름을 지정하여 메서드를 source를 줄 수도 있다.

메서드를 테스트 위로 올린 것은 given / when / then 에서 given절에 해당하기 때문에 위로 올렸다.

공식문서 (링크)를 통해서 더욱 자세히 알 수 있다.

또한 groovy 언어 중 Spock이란 것도 있는데, @ParameterizedTest가 JUnit에서 제공하는 것보다 더욱 간단하다. 공식문서를 통해 더욱 자세히 알 수 있다.

공식문서 링크

2-8. @DynamicTest

임의의 환경을 설정해놓고, 이 환경에 변화를 주면서 중간중간 검증을 하고, 행위를 했을 때 검증이 되는 일련의 시나리오를 테스트하고 싶을 때 사용하기 좋은 것이 다이나믹 테스트이다.

코드를 작성할 때는 보통 다음과 같이 구성하게 된다.

@DisplayName("")
@TestFactory
Collection<DynamicTest> dynamicTest() {

    return List.of(
            DynamicTest.dynamicTest("", () -> {

            }),
            DynamicTest.dynamicTest("", () -> {

            })
    );
}

return 문 전에 given과 같은 환경을 설정하고, 리스트 형태로 동적 테스트 여러 건을 던지면서 일련의 시나리오를 단게별로 행위나 혹은 검증을 하고 싶을 때 이렇게 구성을 할 수 있다.

사용하는 방법은 @Test 대신 @TestFactory를 선언한다.
리턴 값으로 Collection 혹은 Stream과 같이 순회가능한 타입을 주면 된다.
그리고 DynamicTest로 각각의 테스트를 작성한 후 반환을 하면 TestFactory에서 일련의 상황에 맞는 시나리오를 수행하게 할 수 있다.

아래는 재고 차감 시나리오 테스트이다.

@DisplayName("재고 차감 시나리오")
@TestFactory
Collection<DynamicTest> stockDeductionDynamicTest() {
    // given
    Stock stock = Stock.create("001", 1);

    return List.of(
            DynamicTest.dynamicTest("재고를 주어진 개수만큼 차감할 수 있다.", () -> {
                // given
                int quantity = 1;

                // when
                stock.deductQuantity(quantity);

                // then
                assertThat(stock.getQuantity()).isZero();

            }),
            DynamicTest.dynamicTest("재고보다 많은 수의 수량으로 차감 시도하는 경우 예외가 발생한다", () -> {
                // given
                int quantity = 1;

                // when // then
                assertThatThrownBy(() -> stock.deductQuantity(quantity))
                        .isInstanceOf(IllegalArgumentException.class)
                        .hasMessage("차감할 재고 수량이 없습니다.");
            })
    );
}

테스트 결과이다.

이처럼 공통의 환경으로부터 출발해서 단계별로 시나리오 별로 검증해나갈 수 있다.

2-9. 테스트 수행도 비용이다. 환경 통합하기

실제 실무에서 테스트를 할 때도 클래스의 전체 테스트를 실행한다.

참고로 위와 같이 Gradle의 verification에서 test를 실행하면 전체 테스트를 수행할 수 있다.

이러한 전체 테스트를 수행하는데 있어서 SpringBoot는 몇 번 수행될까?

총 6번이다.

SpringBoot가 뜰 때 수행하는 것들이 많기 때문에 시간이 어느정도 걸리게 된다.
전체 테스트를 여러 번 수행할 수 있어야 하는데, 서버가 뜨는 횟수가 많아진다면 테스트 수행 시간이 길어지게 된다.
테스트 수가 늘어나거나 환경이 바뀌면 바뀔수록 서버 뜨는 횟수가 전체 태스트에서 많아지는데
이는 테스트를 수행할 때 드는 비용이 많아지게 되고 테스트를 더욱 안 하게 된다.

이러한 비용을 계속 관리하면서 어떻게 더욱 빠른 시간 안에 효과적으로 테스트를 수행할 수 있을지 고민해봐야 한다.

현재 6번 뜨는 것의 원인을 찾아 개선시켜보자.

@ActiveProfiles("test")
@SpringBootTest
class OrderServiceTest { ... }

@ActiveProfiles("test")
@SpringBootTest
class ProductServiceTest { ... }

@SpringBootTest
class OrderStatisticsServiceTest { ... }

위의 3개 테스트 클래스는 모두 @SpringBootTest를 사용하는데, 그런데 OrderStatisticsServiceTest에는 프로필 지정이 되어있지 않다.
같은 스프링부트 테스트라도 이런 프로파일 지정과 같은 환경이 조금이라도 달라지면 이 Spring Boot가 별도로 띄워진다.

이러한 부분들을 맞춰서 동일한 환경에서 테스트들이 모두 수행이 될 수 있도록 공통적인 환경을 모아주면 서버가 뜨는 시간을 줄일 수 있다.

이러한 환경들을 통합하기위해 상위 클래스를 하나 만들어 추출을 해볼 것이다.

test - spring 패키지의 하위에 IntegrationTestSupport 클래스를 만든다.

다음과 같이 작성한다.

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;

@ActiveProfiles("test")
@SpringBootTest
public abstract class IntegrationTestSupport {
}

그리고 이 위의 클래스를 각각의 테스트클래스에서 상속받는다.

class ProductServiceTest extends IntegrationTestSupport { ... }
class OrderServiceTest extends IntegrationTestSupport { ... }
class OrderStatisticsServiceTest extends IntegrationTestSupport { ... }

이렇게 해서 세 테스트 모두 동일한 환경으로 구성했다.
그리고 전체 테스트를 실행해서 Spring Boot가 뜨는 횟수가 줄어들었는지 확인해보자.

6개로써 똑같다.

왜냐하면 다른 점이 또 있기 때문이다. OrderStatisticsServiceTest를 보면 다른 점이 있다.

OrderStatisticsServiceTest에서는 @MockBean이 있다.
왜냐하면 "기존의 프로덕션 빈을 Mock객체로 교체하겠다"라는 의미이기 때문에 서버가 다시 띄어져야 하는 상황이 발생한 것이다.

이럴 때 두 가지 선택지가 있다.
@MockBean 처리한 것을 상위클래스에 올릴 수 있다.

또한 protected로 접근제어자를 선언해줘야 하위 클래스에도 사용할 수 있다.

@ActiveProfiles("test")
@SpringBootTest
public abstract class IntegrationTestSupport {

    @MockBean
    protected MailSendClient mailSendClient; // protected
}

하지만 이런 방법은 MockBen이 다른 서비스 테스트에도 MockBen으로 들어간다.

만약 다른 서비스 테스트에서는 @MockBean처리를 하기 싫을 수 있다.

이럴 때는 테스트 환경을 두 개로 나누는 방법도 있다.

MockBean들이 없는 테스트 서포트 상위 클래스를 하나 구축하여 상속을 받게하고,
Mocking 처리를 하는 클래스들을 한번에 다 모아놓을 수 있다.

현재 6번에서 5번으로 줄었다.

참고로 @DataJpaTest도 서버를 새로 띄운다.
따라서 @DataJpaTest를 사용하는 곳에서도 상위 클래스를 상속받게 한다.

//@ActiveProfiles("test")
//@DataJpaTest
class ProductRepositoryTest extends IntegrationTestSupport {

//@DataJpaTest
class StockRepositoryTest extends IntegrationTestSupport {

참고

레포지토리 계층에서 @SpringBootTest를 사용하는 클래스를 상속받는데,
@DataJpaTest 애너테이션을 둔 상위 클래스를 하나 둬서 상속받게 할 수도 있다.
프로젝트 상황에 따라 선택하면 된다.

테스트 클래스 개선 중 실패를 했다.

@DataJpaTest를 사용하는 곳에서 데이터 클렌징을 해줬었는데, @SpringBootTest를 사용하게 되면서 해주지 않으므로 RepositoryTest에는 다음과 같이 @Transactional을 붙여준다.

//@ActiveProfiles("test")
//@DataJpaTest
@Transactional
class ProductRepositoryTest extends IntegrationTestSupport { ... }

@Transactional
class StockRepositoryTest extends IntegrationTestSupport { ... }

현재 스프링 부트를 띄우는 것이 5번에서 3번으로 줄었다.

Controller는 방금까지 했던 Service와 Repository와는 다르다.
다른 계층은 Mock으로 처리하고 validation을 사용해서 검증에 대한 부분을 테스트했기 때문에 성격이 다르다.

WebMvc 테스트를 위한 환경을 맞춰보면 다음과 같다.

//@WebMvcTest(controllers = OrderController.class)
class OrderControllerTest extends ControllerTestSupport {

    @Test

    ...
}

//@WebMvcTest(controllers = ProductController.class) // 컨트롤러를 테스트하기 위해 컨트롤러 관련 빈들만 올릴 수 있는 가벼운 애너테이션이다. 테스트 하고 하자는 컨트롤러를 명시해준다.
class ProductControllerTest extends ControllerTestSupport {

    @Test

    ...
}


@WebMvcTest(controllers = {
        OrderController.class,
        ProductController.class
})
public abstract class ControllerTestSupport {

    @Autowired
    protected MockMvc mockMvc;

    @MockBean
    protected OrderService orderService;

    @Autowired
    protected ObjectMapper objectMapper;


    @MockBean
    protected ProductService productService;


}

테스트를 진행해보면 Spring Boot 서버를 띄우는 횟수가 2회로 줄었다.

2-10. Q. private 메서드의 테스트는 어떻게 하나요?

결론부터 하자면 할 필요가 없다.
하려고 해서도 안 된다.
private 메서드의 테스트하고 싶은 시점에 해야할 고민이 있다.
"객체를 분리할 시점인가?"라는 질문을 해봐야 한다.

어떤 객체가 public method, 다른 관점으로 보면 공개 API를 가지고 있다고 가정했을 때
외부 즉 클라이언트에서 봤을 때는 public method, 공개 API만 알면 된다.
(참고로 외부라는 것은 어떤 A 도메인의 서비스 클래스가 있다면 서비스 클래스를 사용하는 컨트롤러 혹은 서비스 클래스를 테스트하는 테스트 코드가 될 수도 있다.)

클라이언트 입장에서 어떤 서비스 클래스의 private method 즉 외부로 노출되지 않은 내부 기능까지 알아야 될 필요가 없다는 것이다.

그리고 어떤 서비스 클래스에서 public method가 있고, 거기서 private method를 사용한다고 가정했을 때,
public method를 테스트하다보면 로직을 수행하면서 자연스럽게 private method를 검증하게 된다.

public ProductResponse createProduct(ProductCreateServiceRequest request) {
    String nextProductNumber = createNextProductNumber();

    Product product = request.toEntity(nextProductNumber);

    Product savedProduct = productRepository.save(product);

    return ProductResponse.of(savedProduct);
}

private String createNextProductNumber() {
    String latestProductNumber = productRepository.findLatestProductNumber();
    if(latestProductNumber == null) {
        return "001";
    }

    int latestProductNumberInt = Integer.parseInt(latestProductNumber);
    int nextProductNumberInt = latestProductNumberInt + 1;
    return String.format("%03d", nextProductNumberInt);
}

이전에 작성했던 서비스 계층의 메서드이다.
Product를 만들면서 다음 ProductNumber를 구해온다.

상품을 생성하는 책임과 다음 상품 번호를 가져오는 책임을 별개라는 생각을 하면서 다른 클래스로 분리해보자.

@RequiredArgsConstructor
@Component // ProductRepository를 주입받기 위해 빈으로 등록
public class ProductNumberFactory {

    private final ProductRepository productRepository;

    public String createNextProductNumber() {
        String latestProductNumber = productRepository.findLatestProductNumber();
        if(latestProductNumber == null) {
            return "001";
        }

        int latestProductNumberInt = Integer.parseInt(latestProductNumber);
        int nextProductNumberInt = latestProductNumberInt + 1;
        return String.format("%03d", nextProductNumberInt);
    }
}

@RequiredArgsConstructor
@Service
public class ProductService {

    private final ProductRepository productRepository;
    private final ProductNumberFactory productNumberFactory;


    public ProductResponse createProduct(ProductCreateServiceRequest request) {
        String nextProductNumber = productNumberFactory.createNextProductNumber();

        Product product = request.toEntity(nextProductNumber);

        Product savedProduct = productRepository.save(product);

        return ProductResponse.of(savedProduct);
    }


    public List<ProductResponse> getSellingProducts() {
        List<Product> products = productRepository.findAllBySellingStatusIn(ProductSellingStatus.forDisplay());

        return products.stream()
                .map(ProductResponse::of)
                .collect(Collectors.toList());
    }
}

이처럼 상품을 생성하는 책임다른 객체의 상품 번호를 읽어와서 생성하는 책임을 분리함으로써 테스트를 별도로 가져갈 수 있도록 하였다.

private method는 테스트를 할 필요가 없고, public method를 하면서 검증하게된다.
만약 정말 private method를 테스트하고 싶다면 객체를 분리할 시점인지 고민을 해보는 것이 좋다.

2-11. Q. 테스트에서만 필요한 메서드가 생겼는데 프로덕션 코드에서는 필요 없다면?

여기 builder를 하는 부분은 사실 프로덕션 코드에서는 사용할 필요가 없다.
또한 ProductCreateRequest클래스 내의 생성자builder는 테스트에서만 사용하고 있다.

이처럼 테스트에서만 사용되는 코드라면 어떻게 해야될까?

결론부터 말하면 만들어도 된다.
하지만 보수적으로 접근해야 한다.

테스트에서만 사용되는 메서드를 막 만들어내는 것은 지양해야한다.
하지만 무엇을 테스트하고 있는지를 명확히 인지하고 있어야한다.

현재 만들려고 하는 기능에 대한 명세, 그리고 그 명세에 맞는 프로덕션 코드를 예상하여 테스트 코드를 작성하게 되는데, 이러한 과정에서 테스트에서는 필요한테 프로덕션 코드에서는 필요없는 메서드들이 나올 수 있다. 그런 경우에는 만들어도 된다.

예를 들어 Getter, 기본 생성자, 생성자 빌더, (컬렉션의) 사이즈 등 어떤 객체가 마땅히 가져도 되는 행위이고, 미래에도 충분히 사용될 수 있는 성격의 메서드라면 충분히 가져도 좋다.

그렇지만 막 만드는 것은 최대한 지양하는 것이 좋다.

3. 요약

  • 한 가지 테스트에는 한 가지 목적의 검증만 수행을 하는 것이 좋다.
  • 테스트 환경을 완벽하게 제어할 수 있어야 한다.
  • 최대한 독립적인 환경을 만들어서 테스트 하는 것이 좋다.
  • 각 테스트마다 독립적으로 작성하여 문서로서의 기능이 잘 잘동하도록 하고, 중복되는 코드가 있더라도 각 테스트 클래스마다 필요한 데이터를 받도록 일일이 작성해주는 것이 좋다.
  • deletAll()보단 deleteAllInBatch()를 사용하자.
  • @ParameterizedTest를 통해서 하나의 테스트 케이스에 여러 값을 주면서 테스트를 할 수 있다.
  • @DynamicTest를 통해서 하나의 테스트에서 시나리오 테스트를 단계별로 진행할 수 있다.
  • 서버를 띄우는 것도 비용이기 때문에 최대한 서버를 띄우는 횟수를 줄이도록 하는 것이 좋다.
  • private method는 public method를 테스트하는 과정에서 자동으로 검증하게 되고 만약 private method를 테스트하고 싶다면 객체를 분리할 시점인지 고민해보고 분리하면 된다.
  • 테스트에서만 필요한 메서드가 생겼는데 프로덕션 코드에서는 필요 없는 경우, 보수적으로 접근하여 필요하면 만들어도 되지만, 막 만드는 것은 지양해야 한다!!
728x90
Comments