Business-Layer-Test-2
in TDD on Tdd
Business Layer Test 시나리오
현 Business Layer Test는 Given-When-Then 구조로 TDD로 진행
추가요구사항
- 주문 생성 시 재고 확인 및 개수 차감 후 생성하기
- 재고는 상품번호를 가진다.
- 재고와 관련 있는 상품 타입은 병 음료, 베이커리이다.
구현 순서
RED
구매 후 재고 차감 테스트
- OrderServiceTest(재고와 관련된 상품이 포함되어 있는 주문번호 리스트를 받아 주문을 생성한다.)
- Stock Entity 생성
- StockRepository 생성
GREEN
- OrderService 재고 차감 체크가 필요한 상품들 filter
- ProductType 수정
- ProductTypeTest 생성
- 재고 타입 O
- 재고 타입 X
- OrderService 재고 엔티티 조회
- StockRepository
- findAllByProductNumberIn
- StockRepositoryTest 단위 테스트 작성
- 저장 후 조회
- StockRepositoryTest 단위 테스트 작성
- findAllByProductNumberIn
- StockRepository
- OrderService 상품별 counting
- OrderService 재고 차감 시도
- Stock isQuantityLessThan 재고 확인
- StockTest isQuantityLessThan 단위 테스트 작성
- 가능
- 불가능
- StockTest deductQantity 재고 이상 차감 시도 단위 테스트 작성
- 불가
- StockTest isQuantityLessThan 단위 테스트 작성
- OrderServiceTest createOrderWithNoStock 재고가 없는 상품으로 주문을 생성하려는 경우 예외가 발생한다.
- Stock isQuantityLessThan 재고 확인
REFACTOR
- OrderService
구현
OrderServiceTest
@ActiveProfiles("test")
@SpringBootTest
class OrderServiceTest {
...
@DisplayName("재고와 관련된 상품이 포함되어 있는 주문번호 리스트를 받아 주문을 생성한다.")
@Test
void createOrderWithStock() {
// 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);
stockRepository.saveAll(List.of(stock1, stock2));
OrderCreateRequest request = OrderCreateRequest.builder()
.productNumbers(List.of("001", "001", "002", "003"))
.build();
// when
OrderResponse orderResponse = orderService.createOrder(request, registeredDateTime);
// then
assertThat(orderResponse.getId()).isNotNull();
assertThat(orderResponse)
.extracting("registeredDateTime", "totalPrice")
.contains(registeredDateTime, 10000);
assertThat(orderResponse.getProducts()).hasSize(4)
.extracting("productNumber", "price")
.containsExactlyInAnyOrder(
tuple("001", 1000),
tuple("001", 1000),
tuple("002", 3000),
tuple("003", 5000)
);
List<Stock> stocks = stockRepository.findAll();
assertThat(stocks).hasSize(2)
.extracting("productNumber", "quantity")
.containsExactlyInAnyOrder(
tuple("001", 0),
tuple("002", 1)
);
}
@DisplayName("재고가 부족한 상품으로 주문을 생성하려는 경우 예외가 발생한다.")
@Test
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, registeredDateTime))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("재고가 부족한 상품이 있습니다.");
}
...
}
Stock
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Stock extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productNumber;
private int quantity;
@Builder
private Stock(String productNumber, int quantity) {
this.productNumber = productNumber;
this.quantity = quantity;
}
public static Stock create(String productNumber, int quantity) {
return Stock.builder()
.productNumber(productNumber)
.quantity(quantity)
.build();
}
public boolean isQuantityLessThan(int quantity) {
return this.quantity < quantity;
}
public void deductQuantity(int quantity) {
if (isQuantityLessThan(quantity)) {
throw new IllegalArgumentException("차감할 재고 수량이 없습니다.");
}
this.quantity -= quantity;
}
}
StockRepository
@Repository
public interface StockRepository extends JpaRepository<Stock, Long> {
List<Stock> findAllByProductNumberIn(List<String> productNumbers);
}
OrderService
@Transactional
@RequiredArgsConstructor
@Service
public class OrderService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
private final StockRepository stockRepository;
public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTime) {
List<String> productNumbers = request.getProductNumbers();
List<Product> products = findProductsBy(productNumbers);
deductStockQuantities(products);
Order order = Order.create(products, registeredDateTime);
Order savedOrder = orderRepository.save(order);
return OrderResponse.of(savedOrder);
}
private void deductStockQuantities(List<Product> products) {
List<String> stockProductNumbers = extractStockProductNumbers(products);
Map<String, Stock> stockMap = createStockMapBy(stockProductNumbers);
Map<String, Long> productCountingMap = createCountingMapBy(stockProductNumbers);
for (String stockProductNumber : new HashSet<>(stockProductNumbers)) {
Stock stock = stockMap.get(stockProductNumber);
int quantity = productCountingMap.get(stockProductNumber).intValue();
if (stock.isQuantityLessThan(quantity)) {
throw new IllegalArgumentException("재고가 부족한 상품이 있습니다.");
}
stock.deductQuantity(quantity);
}
}
private List<Product> findProductsBy(List<String> productNumbers) {
List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);
Map<String, Product> productMap = products.stream()
.collect(Collectors.toMap(Product::getProductNumber, p -> p));
return productNumbers.stream()
.map(productMap::get)
.collect(Collectors.toList());
}
private static List<String> extractStockProductNumbers(List<Product> products) {
return products.stream()
.filter(product -> ProductType.containsStockType(product.getType()))
.map(Product::getProductNumber)
.collect(Collectors.toList());
}
private Map<String, Stock> createStockMapBy(List<String> stockProductNumbers) {
List<Stock> stocks = stockRepository.findAllByProductNumberIn(stockProductNumbers);
return stocks.stream()
.collect(Collectors.toMap(Stock::getProductNumber, s -> s));
}
private static Map<String, Long> createCountingMapBy(List<String> stockProductNumbers) {
return stockProductNumbers.stream()
.collect(Collectors.groupingBy(p -> p, Collectors.counting()));
}
}
ProductType
@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);
}
}
ProductTypeTest
class ProductTypeTest {
@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();
}
}
StockRepositoryTest
@DataJpaTest
class StockRepositoryTest {
@Autowired
private StockRepository stockRepository;
@DisplayName("상품번호 리스트로 재고를 조회한다.")
@Test
void findAllByProductNumberIn() {
// given
Stock stock1 = Stock.create("001", 1);
Stock stock2 = Stock.create("002", 2);
Stock stock3 = Stock.create("003", 3);
stockRepository.saveAll(List.of(stock1, stock2, stock3));
// when
List<Stock> stocks = stockRepository.findAllByProductNumberIn(List.of("001", "002"));
// then
assertThat(stocks).hasSize(2)
.extracting("productNumber", "quantity")
.containsExactlyInAnyOrder(
tuple("001", 1),
tuple("002", 2)
);
}
}
StockTest
class StockTest {
@DisplayName("재고의 수량이 제공된 수량보다 작은지 확인한다.")
@Test
void isQuantityLessThan() {
// given
Stock stock = Stock.create("001", 1);
int quantity = 2;
// when
boolean result = stock.isQuantityLessThan(quantity);
// then
assertThat(result).isTrue();
}
@DisplayName("재고를 주어진 개수만큼 차감할 수 있다.")
@Test
void deductQuantity() {
// given
Stock stock = Stock.create("001", 1);
int quantity = 1;
// when
stock.deductQuantity(quantity);
// then
assertThat(stock.getQuantity()).isZero();
}
@DisplayName("재고보다 많은 수의 수량으로 차감 시도하는 경우 예외가 발생한다.")
@Test
void deductQuantity2() {
// given
Stock stock = Stock.create("001", 1);
int quantity = 2;
// when // then
assertThatThrownBy(() -> stock.deductQuantity(quantity))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("차감할 재고 수량이 없습니다.");
}
}
tip
@SpringBootTest vs @DataJpaTest
@DataJpaTest는 @SpringBootTest에 비해 가볍고 트랜잭션을 보장함
하지만 Service단 인지가 안되고 테스트의 범위가 작다.
테스트시 @Transaction을 걸거나 @DataJpaTest를 사용하게 되면 테스트의 편의성은 증가할 수 있으나, 실제 Service단의 @Transaction을 놓칠수 있으니 주의가 필요하다.
@AfterEach란?
한번에 여러 테스트를 실행하면 메모리 DB에 직전 테스트의 결과가 남을 수 있다.
이렇게 되면 다음 이전 테스트 때문에 다음 테스트가 실패할 가능성이 있다.
@AfterEach 를 사용하면
각 테스트가 종료될 때 마다 이 기능을 실행한다.
여기서는 메모리 DB에 저장된 데이터를 삭제한다.
테스트는 각각 독립적으로 실행되어야 한다.
테스트 순서에 의존관계가 있는 것은 좋은 테스트가 아니다.
Git Link
(https://github.com/dadaok/practical-testing/tree/lesson5-7)[https://github.com/dadaok/practical-testing/tree/lesson5-7]