Persistence Layer
- Data Access의 역할
- 비즈니스 가공 로직이 포함되어서는 안 된다.
- Data에 대한 CRUD에만 집중한 레이어
Business Layer
- 비즈니스 로직을 구현하는 역할
- Persistence Layer와의 상호작용(Data를 읽고 쓰는 행위)을 통해 비즈니스 로직을 전개시킨다.
- 트랜잭션을 보장해야 한다.
요구사항
- 상품 번호 리스트를 받아 주문 생성하기
- 주문은 주문 상태, 주문 등록 시간을 가진다.
- 주문의 총 금액을 계산할 수 있어야 한다.
Order Entity
@Entity
@Getter
@Table(name = "Orders")
public class Order extends BaseEntity {
@Id @GeneratedValue(strategy = IDENTITY)
private Long id;
@Enumerated(STRING)
private OrderStatus orderStatus;
private int totalPrice;
private LocalDateTime registeredDateTime;
@OneToMany(mappedBy = "order", cascade = ALL)
private List<OrderProduct> orderProducts;
}
- Order는 order by 예약어이기 때문에 Orders -> s를 붙여준다.
OrderStatus Enum
@Getter
@RequiredArgsConstructor
public enum OrderStatus {
INIT("주문생성"),
CANCELED("주문취소"),
PAYMENT_COMPLETE("결제완료"),
PAYMENT_FAILED("결제실패"),
RECEIVED("주문접수"),
COMPLETE("처리완료");
private final String text;
}
Product Entity
@Entity
@NoArgsConstructor(access = PROTECTED)
@Getter
public class Product extends BaseEntity {
@Id @GeneratedValue(strategy = IDENTITY)
private Long id;
private String productNumber;
@Enumerated(STRING)
private ProductType type;
@Enumerated(STRING)
private ProductSellingStatus sellingStatus;
private String name;
private int price;
@Builder
public Product(String productNumber, ProductType type, ProductSellingStatus sellingStatus,
String name, int price) {
this.productNumber = productNumber;
this.type = type;
this.sellingStatus = sellingStatus;
this.name = name;
this.price = price;
}
}
OrderProduct - 주문과 상품의 중간다리 (N:M)
@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
public class OrderProduct extends BaseEntity {
@Id @GeneratedValue(strategy = IDENTITY)
private Long id;
@ManyToOne(fetch = LAZY)
private Product product;
@ManyToOne(fetch = LAZY)
private Order order;
public OrderProduct(Product product, Order order) {
this.product = product;
this.order = order;
}
}
우리가 구현해야할 기능을 TDD로 테스트해보자.
상품 번호 리스트 받아 주문 생성
주문은 주문상태, 주문 등록 시간을 가진다.
주문의 총 금액을 계산할 수 있어야 한다.
RED
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public OrderResponse createOrder(OrderCreateRequest request) {
return null;
}
}
@Getter
public class OrderResponse {
private Long id;
private int totalPrice;
private LocalDateTime registeredDateTime;
private List<ProductResponse> products;
}
@ActiveProfiles("test")
@DataJpaTest
class OrderServiceTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private OrderService orderService;
@DisplayName("주문번호 목록을 받아 주문을 생성한다.")
@Test
void createOrder() {
// given
Product product1 = createProduct(HANDMADE,"001", 1000);
Product product2 = createProduct(HANDMADE,"002", 3000);
Product product3 = createProduct(HANDMADE,"003", 4000);
productRepository.saveAll(List.of(product1, product2, product3));
OrderCreateRequest orderCreateRequest = OrderCreateRequest.builder()
.productNumbers(List.of("001", "002"))
.build();
// when
OrderResponse orderResponse = orderService.createOrder(orderCreateRequest);
// then
assertThat(orderResponse.getId()).isNotNull();
assertThat(orderResponse)
.extracting("registeredDateTime", "totalPrice")
.containsExactlyInAnyOrder(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)
.name("메뉴이름")
.build();
}
}
GREEN
너무 길어서 구현 코드:
https://github.com/beginner0107/cafekiosk/commit/ef22249094f7833880457d7238d2d51a22d481c8
Order Entity - Cascade를 이용해서 order객체와, product객체를 가지고 orderProduct객체에 더해준 후.
order.save 작성 -> insert쿼리가 3방 나간다.
Order Entity에 비즈니스 로직을 넣어주었다. -> DDD
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(product, this))
.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();
}
createOrder - 테스트코드 실행결과
@DisplayName("주문번호 목록을 받아 주문을 생성한다.")
@Test
void createOrder() {
// given
Product product1 = createProduct(HANDMADE,"001", 1000);
Product product2 = createProduct(HANDMADE,"002", 3000);
Product product3 = createProduct(HANDMADE,"003", 4000);
productRepository.saveAll(List.of(product1, product2, product3));
OrderCreateRequest orderCreateRequest = OrderCreateRequest.builder()
.productNumbers(List.of("001", "002"))
.build();
// when
LocalDateTime registeredDateTime = LocalDateTime.now();
OrderResponse orderResponse = orderService.createOrder(orderCreateRequest, registeredDateTime);
// then
assertThat(orderResponse.getId()).isNotNull();
assertThat(orderResponse)
.extracting("registeredDateTime", "totalPrice")
.containsExactlyInAnyOrder(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)
.name("메뉴이름")
.build();
}
insert
into
orders
(id, created_date_time, modified_date_time, order_status, registered_date_time, total_price)
values
(default, ?, ?, ?, ?, ?)
Hibernate:
insert
into
order_product
(id, created_date_time, modified_date_time, order_id, product_id)
values
(default, ?, ?, ?, ?)
Hibernate:
insert
into
order_product
(id, created_date_time, modified_date_time, order_id, product_id)
values
(default, ?, ?, ?, ?)
- 중복된 상품 주문이 들어왔을 때 처리가 안 되어 있다.!
public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTime) {
List<String> productNumbers = request.getProductNumbers();
List<Product> products = productRepository.findAllByProductNumberIn(productNumbers);
Map<String, Product> productMap = products.stream()
.collect(Collectors.toMap(Product::getProductNumber, p -> p));
List<Product> duplicateProducts = productNumbers.stream()
.map(productMap::get)
.collect(Collectors.toList());
Order order = Order.create(duplicateProducts, registeredDateTime);
Order savedOrder = orderRepository.save(order);
return OrderResponse.of(savedOrder);
}
@DisplayName("중복되는 상품번호 리스트로 주문을 생성할 수 있다.")
@Test
void createOrderWithDuplicateProductNumber() {
// given
Product product1 = createProduct(HANDMADE,"001", 1000);
Product product2 = createProduct(HANDMADE,"002", 3000);
Product product3 = createProduct(HANDMADE,"003", 4000);
productRepository.saveAll(List.of(product1, product2, product3));
OrderCreateRequest orderCreateRequest = OrderCreateRequest.builder()
.productNumbers(List.of("001", "001"))
.build();
// when
LocalDateTime registeredDateTime = LocalDateTime.now();
OrderResponse orderResponse = orderService.createOrder(orderCreateRequest, registeredDateTime);
// then
assertThat(orderResponse.getId()).isNotNull();
assertThat(orderResponse)
.extracting("registeredDateTime", "totalPrice")
.containsExactlyInAnyOrder(registeredDateTime, 2000);
assertThat(orderResponse.getProducts()).hasSize(2)
.extracting("productNumber", "price")
.containsExactlyInAnyOrder(
tuple("001", 1000),
tuple("001", 1000)
);
}
- productMap을 돌려서, productNumber(상품코드)가 같으면 Map에 <productNumber, Product> 이렇게 담기고
- 또 productNumbers를 stream()으로 돌리면서 List<Product>에 담아주어 중복을 허용하게끔 한다.(List의 특성!)
REFACTOR
public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTime) {
List<String> productNumbers = request.getProductNumbers();
List<Product> duplicateProducts = findProduct(productNumbers);
Order order = Order.create(duplicateProducts, registeredDateTime);
Order savedOrder = orderRepository.save(order);
return OrderResponse.of(savedOrder);
}
private List<Product> findProduct(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());
}
- 중복된 상품을 허용하는 리스트를 가져오는 메서드를 분리하였다.
1. 문제발생 비상~(해보고 싶었다)
- 두 개의 테스트를 각각 돌리게 되면 통과하게 되는 것을 볼 수 있으나,,,
- 같이 돌리게 될 경우 테스트가 독립적으로 수행되는게 아니라, 서로에게 영향을 끼친다.!
- 지금의 경우에는 상품(product)객체를 생성해서 INSERT를 할 때 중복된 키값으로 인해
- 이런 상황이 발생하게 된다.
테스트가 끝날 때마다 db에 저장한 값을 초기화 시켜줘야 한다.
@AfterEach
void tearDown() {
orderProductRepository.deleteAllInBatch();
orderRepository.deleteAllInBatch();
productRepository.deleteAllInBatch();
}
2. 문제발생 비상~
- @DataJpaTest의 경우 각각의 테스트가 독립적으로 수행되었다.
- @SpringBootTest의 경우 독립적인 테스트가 불가능했다.(AfterEach적용 전)
- 무엇 때문일까? DataJpaTest를 Ctrl + Click 해보자
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@BootstrapWith(DataJpaTestContextBootstrapper.class)
@ExtendWith(SpringExtension.class)
@OverrideAutoConfiguration(enabled = false)
@TypeExcludeFilters(DataJpaTypeExcludeFilter.class)
@Transactional
@AutoConfigureCache
@AutoConfigureDataJpa
@AutoConfigureTestDatabase
@AutoConfigureTestEntityManager
@ImportAutoConfiguration
public @interface DataJpaTest {
@Transactional이 보인다.!
2023-12-16 21:51:24.830 INFO 18636 --- [ main] o.s.t.c.transaction.TransactionContext : Rolled back transaction for test: [DefaultTestContext@a3d9978 testClass = ProductRepositoryTest, testInstance = sample.cafekiosk.spring.domain.product.ProductRepositoryTest@4d93e5bf, testMethod = findAllByProductNumberIn@ProductRepositoryTest, testException = [null], mergedContextConfiguration = [MergedContextConfiguration@61544ae6 testClass = ProductRepositoryTest, locations = '{}', classes = '{class sample.cafekiosk.spring.CafekioskApplication}', contextInitializerClasses = '[]', activeProfiles = '{test}', propertySourceLocations = '{}', propertySourceProperties = '{org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTestContextBootstrapper=true}', contextCustomizers = set[org.springframework.boot.test.autoconfigure.OverrideAutoConfigurationContextCustomizerFactory$DisableAutoConfigurationContextCustomizer@9816741, org.springframework.boot.test.autoconfigure.actuate.metrics.MetricsExportContextCustomizerFactory$DisableMetricExportContextCustomizer@2f67a4d3, org.springframework.boot.test.autoconfigure.filter.TypeExcludeFiltersContextCustomizer@351584c0, org.springframework.boot.test.autoconfigure.properties.PropertyMappingContextCustomizer@a394ce5c, org.springframework.boot.test.autoconfigure.web.servlet.WebDriverContextCustomizerFactory$Customizer@7ac296f6, [ImportsContextCustomizer@4b41dd5c key = [org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration, org.springframework.boot.autoconfigure.data.jpa.JpaRepositoriesAutoConfiguration, org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration, org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration, org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration, org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration, org.springframework.boot.autoconfigure.sql.init.SqlInitializationAutoConfiguration, org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration, org.springframework.boot.test.autoconfigure.jdbc.TestDatabaseAutoConfiguration, org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManagerAutoConfiguration]], org.springframework.boot.test.context.filter.ExcludeFilterContextCustomizer@16c069df, org.springframework.boot.test.json.DuplicateJsonObjectContextCustomizerFactory$DuplicateJsonObjectContextCustomizer@26adfd2d, org.springframework.boot.test.mock.mockito.MockitoContextCustomizer@0, org.springframework.boot.test.context.SpringBootTestArgs@1, org.springframework.boot.test.context.SpringBootTestWebEnvironment@0], contextLoader = 'org.springframework.boot.test.context.SpringBootContextLoader', parent = [null]], attributes = map['org.springframework.test.context.event.ApplicationEventsTestExecutionListener.recordApplicationEvents' -> false]]
- rollback을 시켜주는 것을 확인할 수 있다.(DataJpaTest)
- @Transactional이 @SpringBootTest에 붙어있지 않기 때문
'Spring관련 기술 > 테스트코드' 카테고리의 다른 글
Presentation Layer 테스트(1) (0) | 2023.12.19 |
---|---|
Business Layer 테스트 (2) (0) | 2023.12.17 |
Persistence Layer 테스트 (1) | 2023.12.14 |
Spring & JPA 기반 테스트 (0) | 2023.12.13 |
BDD, Behavior Driven Development (0) | 2023.12.12 |