[Practical Testing: 실용적인 테스트 가이드] Spring & JPA 기반 테스트 - Business Layer 테스트
Persistence Layer vs Business Layer
Persistence Layer
- Data Access의 역할
- 비즈니스 가공 로직이 포함되어서는 안됨
- Data에 대한 CRUD에만 집중한 레이어
Business Layer
- 비즈니스 로직을 구현하는 역할
- Persistence Layer화의 상호작용(Data를 읽고 쓰는 행위)을 통해 비즈니스 로직을 전개시킴
- 트랜잭션을 보장해야 함
요구사항
- 상품 번호 리스트를 받아 주문 생성하기
- 주문은 주문 상태, 주문 등록 시간을 가진다
- 주문의 총 금액을 계산할 수 있어야 한다
TDD로 OrderService 테스트하기
1. RED
OrderService 클래스 생성
@Service
public class OrderService {
public OrderResponse createOrder(OrderCreateRequest request) {
return null;
}
}
OrderServiceTest 작성 - 주문 번호 리스트를 받아 주문을 생성한다.
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private ProductRepository productRepository;
@DisplayName("주문번호 리스트를 받아 주문을 생성한다.")
@Test
void createOrder() {
// given
Product product1 = createProduct(ProductType.HANDMADE, "001", 1000);
Product product2 = createProduct(ProductType.HANDMADE, "002", 3000);
Product product3 = createProduct(ProductType.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);
// then
assertThat(orderResponse.getId()).isNotNull();
assertThat(orderResponse).extracting("registeredDateTime", "totalPrice").contains(
LocalDateTime.now(), 4000);
assertThat(orderResponse.getProducts()).hasSize(2)
.extracting("productNumber", "price")
.containsExactlyInAnyOrder(
tuple("001", 1000),
tuple("002", 3000)
);
}
private Product createProduct(ProductType type, String productNumber, int price) {
return Product.builder()
.type(type)
.productNumber(productNumber)
.price(price)
.sellingStatus(ProductSellingStatus.SELLING)
.name("메뉴 이름")
.build();
}
}
2. GREEN
OrderService 기능 구현 - 요청 값을 통해 주문을 생성
@Service
@RequiredArgsConstructor
public class OrderService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTime) {
List<String> productNumbers = request.getProductNumbers();
List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);
Order order = Order.create(products, registeredDateTime);
Order savedOrder = orderRepository.save(order);
return OrderResponse.of(savedOrder);
}
}
Order 엔티티 내에 상품의 총 가격을 계산해주는 private 메서드 추가
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "orders")
@Entity
public class Order extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Enumerated(EnumType.STRING)
private OrderStatus orderStatus;
private int totalPrice;
private LocalDateTime registeredDateTime;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderProduct> orderProducts = new ArrayList<>();
public Order(List<Product> products, LocalDateTime registeredDateTime) {
this.orderStatus = OrderStatus.INIT;
this.totalPrice = calculateTotalPrice(products);
this.registeredDateTime = registeredDateTime;
this.orderProducts = products.stream()
.map(product -> new OrderProduct(this, product))
.collect(Collectors.toList());
}
public static Order create(List<Product> products, LocalDateTime registeredDateTime) {
return new Order(products, registeredDateTime);
}
private int calculateTotalPrice(List<Product> products) {
return products.stream()
.mapToInt(Product::getPrice)
.sum();
}
}
OrderTest에 추가된 메서드에 대한 단위 테스트 코드 작성
class OrderTest {
@DisplayName("주문 생성 시 상품 리스트에서 주문의 총 금액을 계산한다.")
@Test
void calculateTotalPrice() {
// given
List<Product> products = List.of(
createProduct("001", 1000),
createProduct("002", 2000)
);
// when
Order order = Order.create(products, LocalDateTime.now());
// then
assertThat(order.getTotalPrice()).isEqualTo(3000);
}
@DisplayName("주문 생성 시 주문 상태는 INIT 이다.")
@Test
void init() {
// given
List<Product> products = List.of(
createProduct("001", 1000),
createProduct("002", 2000)
);
// when
Order order = Order.create(products, LocalDateTime.now());
// then
assertThat(order.getOrderStatus()).isEqualByComparingTo(OrderStatus.INIT);
}
private Product createProduct(String productNumber, int price) {
return Product.builder()
.type(ProductType.HANDMADE)
.productNumber(productNumber)
.price(price)
.sellingStatus(ProductSellingStatus.SELLING)
.name("메뉴 이름")
.build();
}
@DisplayName("주문 생성 시 주문 등록 시간을 기록한다.")
@Test
void registeredDateTime() {
// given
List<Product> products = List.of(
createProduct("001", 1000),
createProduct("002", 2000)
);
LocalDateTime registeredDateTime = LocalDateTime.now();
// when
Order order = Order.create(products, registeredDateTime );
// then
assertThat(order.getRegisteredDateTime()).isEqualTo(registeredDateTime);
}
}
OrderServiceTest에서 초록불 확인
@ActiveProfiles("test")
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private ProductRepository productRepository;
@DisplayName("주문번호 리스트를 받아 주문을 생성한다.")
@Test
void createOrder() {
// given
Product product1 = createProduct(ProductType.HANDMADE, "001", 1000);
Product product2 = createProduct(ProductType.HANDMADE, "002", 3000);
Product product3 = createProduct(ProductType.HANDMADE, "003", 5000);
productRepository.saveAll(List.of(product1, product2, product3));
OrderCreateRequest request = OrderCreateRequest.builder()
.productNumbers(List.of("001", "002"))
.build();
LocalDateTime registeredDateTime = LocalDateTime.now();
// when
OrderResponse orderResponse = orderService.createOrder(request, 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)
);
}
private Product createProduct(ProductType type, String productNumber, int price) {
return Product.builder()
.type(type)
.productNumber(productNumber)
.price(price)
.sellingStatus(ProductSellingStatus.SELLING)
.name("메뉴 이름")
.build();
}
}
3. REFACTOR
중복된 상품을 주문할 수 있도록 OrderService 수정
@Service
@RequiredArgsConstructor
public class OrderService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTime) {
List<String> productNumbers = request.getProductNumbers();
List<Product> duplicateProducts = findProductsBy(productNumbers);
Order order = Order.create(duplicateProducts, registeredDateTime);
Order savedOrder = orderRepository.save(order);
return OrderResponse.of(savedOrder);
}
private List<Product> findProductsBy(List<String> productNumbers) {
List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);
Map<String, Product> productMap = products.stream()
.collect(Collectors.toMap(Product::getProductNumber, product -> product));
return productNumbers.stream()
.map(productNumber -> productMap.get(productNumber))
.collect(Collectors.toList());
}
}
클렌징 메소드 추가
- DataJpaTest에는 @Transactional로 매 테스트 시 롤백되어 별도의 클렌징 메소드 필요 없음
@AfterEach
void tearDown() {
orderProductRepository.deleteAllInBatch();
productRepository.deleteAllInBatch();
orderRepository.deleteAllInBatch();
}
테스트 코드 작성 - 중복된 상품 번호 리스트로 주문 생성
@DisplayName("중복되는 상품 번호 리스트로 주문을 생성할 수 있다.")
@Test
void createOrderWithDuplicateProductNumbers() {
// given
Product product1 = createProduct(ProductType.HANDMADE, "001", 1000);
Product product2 = createProduct(ProductType.HANDMADE, "002", 3000);
Product product3 = createProduct(ProductType.HANDMADE, "003", 5000);
productRepository.saveAll(List.of(product1, product2, product3));
OrderCreateRequest request = OrderCreateRequest.builder()
.productNumbers(List.of("001", "001"))
.build();
LocalDateTime registeredDateTime = LocalDateTime.now();
// when
OrderResponse orderResponse = orderService.createOrder(request, registeredDateTime);
// then
assertThat(orderResponse.getId()).isNotNull();
assertThat(orderResponse).extracting("registeredDateTime", "totalPrice").contains(
registeredDateTime, 2000);
assertThat(orderResponse.getProducts()).hasSize(2)
.extracting("productNumber", "price")
.containsExactlyInAnyOrder(
tuple("001", 1000),
tuple("001", 1000)
);
}
추가된 요구사항에 대한 테스트 작성
요구사항
- 주문 생성 시 재고 확인 및 개수 차감 후 생성하기
- 재고는 상품 번호를 가진다
- 재고와 관련 있는 상품 타입은 병 음료, 베이커리이다
Stock 엔티티 생성
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Stock extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String productNumber;
private int quantity;
@Builder
public 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;
}
}
Stock 엔티티 테스트 코드
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)
.hasMessageContaining("차감할 재고 수량이 없습니다.");
}
}
OrderService 내에 재고 관련 코드 추가
public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTime) {
List<String> productNumbers = request.getProductNumbers();
List<Product> products = findProductsBy(productNumbers);
// 재고 차감 체크가 필요한 상품들 filter
List<String> stockProductNumbers = products.stream()
.filter(product -> ProductType.containsStockType(product.getType()))
.map(Product::getProductNumber)
.collect(Collectors.toList());
// 재고 엔티티 조히
List<Stock> stocks = stockRepository.findAllByProductNumberIn(stockProductNumbers);
Map<String, Stock> stockMap = stocks.stream()
.collect(Collectors.toMap(Stock::getProductNumber, s -> s));
// 상품별 counting
Map<String, Long> productCountingMap = stockProductNumbers.stream()
.collect(Collectors.groupingBy(p -> p, Collectors.counting()));
// 재고 차감 시도
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);
}
Order order = Order.create(products, registeredDateTime);
Order savedOrder = orderRepository.save(order);
return OrderResponse.of(savedOrder);
}
OrderService 테스트 작성
- @Transactional을 붙여서 별도의 클렌징 메서드 없이 잘 수행됨
- 이때 @Transactional은 production code에 transactional 설정이 된 것처럼 동작하기 때문에 주의 필요
@DisplayName("재고와 관련된 상품이 포함되어 있는 주문번호 리스트를 받아 주문을 생성한다.")
@Test
void createOrderWithStock() {
// given
Product product1 = createProduct(ProductType.BOTTLE, "001", 1000);
Product product2 = createProduct(ProductType.BAKERY, "002", 3000);
Product product3 = createProduct(ProductType.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();
LocalDateTime registeredDateTime = LocalDateTime.now();
// 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
Product product1 = createProduct(ProductType.BOTTLE, "001", 1000);
Product product2 = createProduct(ProductType.BAKERY, "002", 3000);
Product product3 = createProduct(ProductType.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();
LocalDateTime registeredDateTime = LocalDateTime.now();
// when // then
assertThatThrownBy(() -> orderService.createOrder(request, registeredDateTime))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("재고가 부족한 상품이 있습니다.");
}
리팩토링
- 재고 차감 및 stream() 등 을 통해 데이터를 가공하는 부분들을 별도의 메서드로 빼줌
- 메서드명으로 한 단계 더 추상화할 수 있는 이점
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) {
// 재고 차감 체크가 필요한 상품들 filter
List<String> stockProductNumbers = extractStockProductNumbers(products);
// 재고 엔티티 조히
Map<String, Stock> stockMap = createStockMapBy(stockProductNumbers);
// 상품별 counting
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 static List<String> extractStockProductNumbers(List<Product> products) {
List<String> stockProductNumbers = products.stream()
.filter(product -> ProductType.containsStockType(product.getType()))
.map(Product::getProductNumber)
.collect(Collectors.toList());
return stockProductNumbers;
}
private Map<String, Stock> createStockMapBy(List<String> stockProductNumbers) {
List<Stock> stocks = stockRepository.findAllByProductNumberIn(stockProductNumbers);
Map<String, Stock> stockMap = stocks.stream()
.collect(Collectors.toMap(Stock::getProductNumber, s -> s));
return stockMap;
}
private static Map<String, Long> createCountingMapBy(List<String> stockProductNumbers) {
Map<String, Long> productCountingMap = stockProductNumbers.stream()
.collect(Collectors.groupingBy(p -> p, Collectors.counting()));
return productCountingMap;
}
리팩터링 후에도 테스트 통과
재고 감소 -> 동시성 고민
동시에 여러 개의 재고 감소 요청이 들어온다면? 우선순위를 어떻게 할 것?
optimistic lock / pessimistic lock / .. 등 동시성 처리 관련 레퍼런스 찾아볼 것
강사님이 한 메서드 만드실때마다 해당 메서드의 테스트 코드 만드시는 것을 보고 몹시,, 몹시 놀랐다
언제 어떻게 코드가 바뀔지 모르니 자잘자잘한 메서드까지 테스트 해야된다고 하신다
오늘도 한 수 배워 갑니다,,
그리고 나는 github copliot까지 쓰는데 항상 강사님보다 코드 작성하는 속도가 느리다
껄껄
단축키를 좀 더 익혀놔야겠다
Reference
Practical Testing: 실용적인 테스트 가이드 - 인프런 | 강의
이 강의를 통해 실무에서 개발하는 방식 그대로, 깔끔하고 명료한 테스트 코드를 작성할 수 있게 됩니다. 테스트 코드가 왜 필요한지, 좋은 테스트 코드란 무엇인지 궁금하신 모든 분을 위한 강
www.inflearn.com