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);
}
@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);
}
더 많은 test방법이 존재한다.
참조) https://junit.org/junit5/docs/current/user-guide/#writing-tests-parameterized-tests
하나의 공유변수를 가지고 테스트 하는것을 지양해야하므로 대신에
어떤 환경에대한 시나리오 테스트를 진행하는데 사용할수있는 방법
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감소 시켜서 오류가 발생하는지 검사
작성법
Collection<DynamicTest>
리턴Spring Boot를 실행되는 과정이 시간이 가장 많이 소모된다.
-> 최대한 SpringBoot를 실행되는 횟수를 줄여야한다.
-> 테스트 환경을 통합하여 해결
같은 Profile을 공유하여야 한다.
테스트 환경이 달라져서는 안된다.
@ActiveProfiles("test")
@SpringBootTest
public abstract class IntegrationTestSupport {
}
class OrderStatisticsServiceTest extends IntegrationTestSupport
@ActiveProfiles("test")
@SpringBootTest
public abstract class IntegrationTestSupport {
@MockBean
protected MailSendClient mailSendClient;
}
상황에 따라서 MockBean을 띄우는 클래스만 통합할수도 있다.
가볍게 모킹을 이용하다보니 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;
}
진행하지 않아도 된다.
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);
}
}
만들어도 된다. 하지만 보수적으로 접근해야한다.
최대한 지양하고 다음과 같은경우 사용