TDD Tips (2)

qkdk·2024년 2월 6일
0

TDD

목록 보기
12/12

ParmeterizedTest

TDD Tips (1)의 한문단에 한 주제 규칙으로 인해 많아진 테스트코드들의 중복을 제거할수있는 방법

기존 코드

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

        // when
        boolean result = ProductType.containsStockType(givenType);

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

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

        // when
        boolean result = ProductType.containsStockType(givenType);

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

@CsvSource 사용

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

        // then
        assertThat(result).isEqualTo(expected);
    }
  • @CsvSource안에 간단하게 키밸류 형태로 넣어준다.
  • 각각의 타입에 맞는 파라미터를 통해 메서드로 주입

@MethodSource 사용

    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 containsStockType5(ProductType productType, boolean expected) {
        // when
        boolean result = ProductType.containsStockType(productType);

        // then
        assertThat(result).isEqualTo(expected);
    }
  • @MethodSource 내부에 Argument의 Stream을 반환하는 static 매서드의 이르을 적어준다.

더 많은 test방법이 존재한다.
참조) https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests

@DynamicTest

하나의 공유변수를 가지고 테스트 하는것을 지양해야하므로 대신에
어떤 환경에대한 시나리오 테스트를 진행하는데 사용할수있는 방법

ex) 제고를 차감시키고 -> 수량이 부족하면 오류가 발생한다
위와 같은 시나리오는 여러개의 테스트코드로 작성하면 가독성에 좋지 않다.

    @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("차감할 재고 수량이 없습니다.");
            })
        );
    }

코드 설명
1. 1개의 Stock을 가지는 엔티티 생성
2. 첫번째 테스트에서 제고를 1감소 시키고 검사
3. 다시한번 1감소 시켜서 오류가 발생하는지 검사

작성법

  • @Test 대신 @TestFactory 사용
  • Collection<DynamicTest> 리턴

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

Spring Boot를 실행되는 과정이 시간이 가장 많이 소모된다.
-> 최대한 SpringBoot를 실행되는 횟수를 줄여야한다.
-> 테스트 환경을 통합하여 해결

  • 기존 6번 SpringBoot 가 실행됨

통합하는 방법

  • 같은 Profile을 공유하여야 한다.

  • 테스트 환경이 달라져서는 안된다.

    • ex) MockBean과 같이 Spring Boot의 환경이 달라지는 경우
    • ex) SpringBootTest, DataJpaTest와 같이 Spring Boot의 환경이 달라지는 경우

추상 클래스를 활용하여 환경 통합하기

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

}
class OrderStatisticsServiceTest extends IntegrationTestSupport
  • Production 패키지가 아닌 Test 패키지 내에 생서한다.

MockBean으로 인한 환경이 다른 테스트 통합하기

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

    @MockBean
    protected MailSendClient mailSendClient;
}
  • 상위 클래스에 빈을 등록한다.
  • 하위 클래스가 사용할수 있도록 접근 제어자는 protected

상황에 따라서 MockBean을 띄우는 클래스만 통합할수도 있다.

Controller Test 통합하기

가볍게 모킹을 이용하다보니 Service, Repository와 함께 통합하기 어렵다

추상클래스

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

    @Autowired
    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @MockBean
    protected OrderService orderService;

    @MockBean
    protected ProductService productService;
}
  • @WebMvcTest 하나로 모아주기
  • 관련 필드 하나로 모아주기

private 메서드의 테스트는 어떻게 하나요?

진행하지 않아도 된다.

Client(Controller, test,...) 입장에서 private한 내부 기능까지 알 필요가 없다.

  • 애초에 외부에 가리려고 작성한 private 메서드
  • public 외부 메서드를 테스트할때 자동적으로 private메서드도 테스트 한다.

객체 분리를 고려해야한다.

그럼에도 private 메서드를 테스트 해야하는 로직 같을때 객체분리의 신호일수도 있다.

  • 한 클래스가 너무 많은 기능을 담단하는것은 아닌지? 고려해야한다.
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class ProductService {

    private final ProductRepository productRepository;

    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);
    }

}

객체 분리 코드

@RequiredArgsConstructor
@Component
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);
    }

}

@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class ProductService {

    private final ProductRepository productRepository;
    private final ProductNumberFactory productNumberFactory;

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

        Product product = request.toEntity(nextProductNumber);
        Product savedProduct = productRepository.save(product);

        return ProductResponse.of(savedProduct);
    }
}

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

만들어도 된다. 하지만 보수적으로 접근해야한다.
최대한 지양하고 다음과 같은경우 사용

  • 어떤 객체가 마땅이 가져도 되는 기능
  • 미래에 사용될 가능성이 있는 기능
  • Getter, Builder, ...
profile
qkdk

0개의 댓글

관련 채용 정보