Business-Layer-Test-1
in TDD on Tdd
Business Layer Test 시나리오
현 Business Layer Test는 Given-When-Then 구조로 TDD로 진행
Business Layer
- 비즈니스 로직을 구현하는 역할.
- Persistence Layer와의 상호작용(Data를 읽고 쓰는 행위)을 통해 비즈니스 로직을 전개시킨다.
- 트랙잭션을 보장해야 한다.
요구사항
- 상품 번호 리스트를 받아 주문 생성하기
- 주문은 주문 상태, 주문 등록 시간을 가진다.
- 주문의 총 금액을 계산할 수 있어야 한다.
구현 순서
RED
- Order entity 작성
- OrderStatus(enum) 작성
- OrderProduct entity(중간 테이블) 작성
- OrderController 작성
- OrderCreateRequest 작성
- OrderService 작성(return null로 작성하여 Red테스트)
- OrderServiceTest 작성(TDD구현을 위해 선test후 service작성)
- OrderResponse 작성
GREEN
- OrderService createOrder Product 구문작성
- ProductRepository 추가(findAllByProductNumberIn)
- ProductRespositoryTest 추가
- ProductRepository 추가(findAllByProductNumberIn)
- OrderService createOrder Order 구문작성
- Order 작성
- Order TotalPrice 작성
- RED
- OrderTest
- calculateTotalPrice
- given
- when
- then
- calculateTotalPrice
- OrderTest
- GREEN
- REFACTOR
- OrderTest INIT 테스트 추가(주문 생성 시 주문 등록 시간을 기록한다.)
- given
- when
- then
- RED
- Order TotalPrice 작성
- OrderResponse of 작성
- OrderRepository 작성
- Order 작성
- OrderService 중복 상품 처리
- RED
- OrderServiceTest 작성
- 중복되는 상품번호 리스트로 주문을 생성할 수 있다.
- given
- when
- then
- 중복되는 상품번호 리스트로 주문을 생성할 수 있다.
- OrderServiceTest 작성
- GREEN
- REFACTOR
- OrderServiceTest 전체 테스트 실행시 Transaction 처리
- @AfterEach를 통해 초기화진행(tearDown)
- OrderProductRepository 생성
- tearDown Test작성(클렌징)
- @AfterEach를 통해 초기화진행(tearDown)
- RED
- OrderController 수정
- http Test
구현
Order entity
@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();
}
}
OrderStatus(enum)
@Getter
@RequiredArgsConstructor
public enum OrderStatus {
INIT("주문생성"),
CANCELED("주문취소"),
PAYMENT_COMPLETED("결제완료"),
PAYMENT_FAILED("결제실패"),
RECEIVED("주문접수"),
COMPLETED("처리완료");
private final String text;
}
OrderProduct entity(중간 테이블)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class OrderProduct extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private Order order;
@ManyToOne(fetch = FetchType.LAZY)
private Product product;
public OrderProduct(Order order, Product product) {
this.order = order;
this.product = product;
}
}
OrderController
@RequiredArgsConstructor
@RestController
public class OrderController {
private final OrderService orderService;
@PostMapping("/api/v1/orders/new")
public OrderResponse createOrder(@RequestBody OrderCreateRequest request) {
LocalDateTime registeredDateTime = LocalDateTime.now();
return orderService.createOrder(request, registeredDateTime);
}
}
OrderCreateRequest
@Getter
@NoArgsConstructor
public class OrderCreateRequest {
private List<String> productNumbers;
@Builder
private OrderCreateRequest(List<String> productNumbers) {
this.productNumbers = productNumbers;
}
}
OrderService
@RequiredArgsConstructor
@Service
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 = findProductsBy(productNumbers);
Order order = Order.create(products, 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, p -> p));
return productNumbers.stream()
.map(productMap::get)
.collect(Collectors.toList());
}
}
OrderServiceTest
@ActiveProfiles("test")
@SpringBootTest
class OrderServiceTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private OrderProductRepository orderProductRepository;
@Autowired
private OrderService orderService;
@AfterEach
void tearDown() {
orderProductRepository.deleteAllInBatch();
productRepository.deleteAllInBatch();
orderRepository.deleteAllInBatch();
}
@DisplayName("주문번호 리스트를 받아 주문을 생성한다.")
@Test
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, 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)
);
}
@DisplayName("중복되는 상품번호 리스트로 주문을 생성할 수 있다.")
@Test
void createOrderWithDuplicateProductNumbers() {
// 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", "001"))
.build();
// 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)
);
}
private Product createProduct(ProductType type, String productNumber, int price) {
return Product.builder()
.type(type)
.productNumber(productNumber)
.price(price)
.sellingStatus(SELLING)
.name("메뉴 이름")
.build();
}
}
OrderResponse
@Getter
public class OrderResponse {
private Long id;
private int totalPrice;
private LocalDateTime registeredDateTime;
private List<ProductResponse> products;
@Builder
private OrderResponse(Long id, int totalPrice, LocalDateTime registeredDateTime, List<ProductResponse> products) {
this.id = id;
this.totalPrice = totalPrice;
this.registeredDateTime = registeredDateTime;
this.products = products;
}
public static OrderResponse of(Order order) {
return OrderResponse.builder()
.id(order.getId())
.totalPrice(order.getTotalPrice())
.registeredDateTime(order.getRegisteredDateTime())
.products(order.getOrderProducts().stream()
.map(orderProduct -> ProductResponse.of(orderProduct.getProduct()))
.collect(Collectors.toList())
)
.build();
}
}
ProductRepository
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
/**
* select *
* from product
* where selling_status in ('SELLING', 'HOLD');
*/
List<Product> findAllBySellingStatusIn(List<ProductSellingStatus> sellingStatuses);
List<Product> findAllByProductNumberIn(List<String> productNumbers);
}
ProductRespositoryTest
@ActiveProfiles("test")
//@SpringBootTest
@DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@DisplayName("원하는 판매상태를 가진 상품들을 조회한다.")
@Test
void findAllBySellingStatusIn() {
// given
Product product1 = Product.builder()
.productNumber("001")
.type(HANDMADE)
.sellingStatus(SELLING)
.name("아메리카노")
.price(4000)
.build();
Product product2 = Product.builder()
.productNumber("002")
.type(HANDMADE)
.sellingStatus(HOLD)
.name("카페라떼")
.price(4500)
.build();
Product product3 = Product.builder()
.productNumber("003")
.type(HANDMADE)
.sellingStatus(STOP_SELLING)
.name("팥빙수")
.price(7000)
.build();
productRepository.saveAll(List.of(product1, product2, product3));
// when
List<Product> products = productRepository.findAllBySellingStatusIn(List.of(SELLING, HOLD));
// then
assertThat(products).hasSize(2)
.extracting("productNumber", "name", "sellingStatus")
.containsExactlyInAnyOrder(
tuple("001", "아메리카노", SELLING),
tuple("002", "카페라떼", HOLD)
);
}
@DisplayName("상품번호 리스트로 상품들을 조회한다.")
@Test
void findAllByProductNumberIn() {
// given
Product product1 = Product.builder()
.productNumber("001")
.type(HANDMADE)
.sellingStatus(SELLING)
.name("아메리카노")
.price(4000)
.build();
Product product2 = Product.builder()
.productNumber("002")
.type(HANDMADE)
.sellingStatus(HOLD)
.name("카페라떼")
.price(4500)
.build();
Product product3 = Product.builder()
.productNumber("003")
.type(HANDMADE)
.sellingStatus(STOP_SELLING)
.name("팥빙수")
.price(7000)
.build();
productRepository.saveAll(List.of(product1, product2, product3));
// when
List<Product> products = productRepository.findAllByProductNumberIn(List.of("001", "002"));
// then
assertThat(products).hasSize(2)
.extracting("productNumber", "name", "sellingStatus")
.containsExactlyInAnyOrder(
tuple("001", "아메리카노", SELLING),
tuple("002", "카페라떼", HOLD)
);
}
}
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);
}
@DisplayName("주문 생성 시 주문 등록 시간을 기록한다.")
@Test
void registeredDateTime() {
// given
LocalDateTime registeredDateTime = LocalDateTime.now();
List<Product> products = List.of(
createProduct("001", 1000),
createProduct("002", 2000)
);
// when
Order order = Order.create(products, registeredDateTime);
// then
assertThat(order.getRegisteredDateTime()).isEqualTo(registeredDateTime);
}
private Product createProduct(String productNumber, int price) {
return Product.builder()
.type(HANDMADE)
.productNumber(productNumber)
.price(price)
.sellingStatus(SELLING)
.name("메뉴 이름")
.build();
}
}
OrderRepository
@Repository
public interface OrderRepository extends JpaRepository<Order, Long> {
}
OrderProductRepository
@Repository
public interface OrderProductRepository extends JpaRepository<OrderProduct, Long> {
}
order.http
### 주문 신규 생성
POST localhost:8080/api/v1/orders/new
Content-Type: application/json
{
"productNumbers": [
"001",
"002"
]
}
product.http
### 판매할 수 있는 상품 조회
GET localhost:8080/api/v1/products/selling
tip
@ActiveProfiles(프로파일명)
어떤 profile을 사용할 것인지 정해주는 어노테이션
@SpringBootTest vs @DataJpaTest
@DataJpaTest는 @SpringBootTest에 비해 가볍고 트랜잭션을 보장함
하지만 Service단 인지가 안되고 테스트의 범위가 작다.
테스트시 @Transaction을 걸거나 @DataJpaTest를 사용하게 되면 테스트의 편의성은 증가할 수 있으나, 실제 Service단의 @Transaction을 놓칠수 있으니 주의가 필요하다.