지난 글에서는 Persistence Layer를 테스트하며 기반을 다졌다. 이제 애플리케이션의 핵심, 비즈니스 로직을 구현하는 Business Layer(Service) 를 테스트해볼 차례다.
Business Layer (Service) 테스트
Service 계층은 Persistence Layer와 상호작용하며 비즈니스 로직을 전개하고, 트랜잭션을 보장해야 하는 아주 중요한 역할이다. 여기서는 하위 Repository 계층까지 아우르는 통합 테스트를 진행하며 TDD 방식으로 개발해보자.
요구사항: 상품 번호 리스트를 받아 주문을 생성한다.
TDD 사이클에 따라, 실패하는 테스트부터 작성한다.
// OrderServiceTest.java
@SpringBootTest
@ActiveProfiles("test")
class OrderServiceTest {
@Autowired private OrderService orderService;
@Autowired private ProductRepository productRepository;
@Autowired private OrderRepository orderRepository;
@Autowired private OrderProductRepository orderProductRepository;
// 테스트가 서로 영향을 주지 않도록 데이터를 청소한다.
@AfterEach
void tearDown() {
orderProductRepository.deleteAllInBatch();
orderRepository.deleteAllInBatch();
productRepository.deleteAllInBatch();
}
@DisplayName("상품번호 리스트를 받아 주문을 생성한다.")
@Test
void createOrder() {
// given
// 미리 상품들을 저장해둔다.
Product product1 = createProduct(HANDMADE, "001", 1000);
Product product2 = createProduct(HANDMADE, "002", 3000);
productRepository.saveAll(List.of(product1, product2));
OrderCreateRequest request = OrderCreateRequest.builder()
.productNumbers(List.of("001", "002"))
.build();
// when
OrderResponse orderResponse = orderService.createOrder(request, LocalDateTime.now());
// then
assertThat(orderResponse.getId()).isNotNull();
assertThat(orderResponse.getTotalPrice()).isEqualTo(4000);
assertThat(orderResponse.getProducts()).hasSize(2)
.extracting("productNumber")
.containsExactlyInAnyOrder("001", "002");
}
// ... createProduct() 헬퍼 메서드
}
이 테스트를 통과시키기 위해 Order
도메인 객체와 OrderService
를 구현한다.
TDD 중 만난 문제와 해결 과정
시나리오 추가: 중복된 상품번호로 주문할 수 있어야 한다. (ex. 아메리카노 2잔)
새로운 테스트 케이스(createOrderWithDuplicateProductNumbers
)를 추가하고 돌려보니, 실패했다. 기존 productRepository.findAllByProductNumberIn()
은 IN ('001', '001')
쿼리를 날려 상품을 하나만 가져왔기 때문이다.
이 문제를 해결하기 위해 서비스 로직을 수정했다.
// OrderService.java
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);
}
// 상품 번호 목록으로 Product 엔티티 맵을 만들고, 다시 상품 번호 목록으로 조회해 중복을 처리
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)
.toList();
}
테스트의 가장 큰 함정: 데이터 격리
각각의 테스트는 성공하는데, 클래스의 모든 테스트를 한 번에 실행하니 실패하는 경우가 있다. 바로 테스트 간 데이터 격리가 되지 않았기 때문이다.
@DataJpaTest
는 테스트마다 트랜잭션을 걸고 끝나면 롤백해주지만,@SpringBootTest
는 그런 기능이 없다.
따라서 @SpringBootTest
를 사용할 때는 한 테스트가 만든 데이터가 다음 테스트에 영향을 주지 않도록 직접 데이터를 지워줘야 한다. 이것이 위 코드에 @AfterEach
로 tearDown()
메서드를 만든 이유다.
주의! 그럼 테스트 클래스에
@Transactional
을 붙이면 되지 않을까?
편리하긴 하지만 아주 위험한 발상이다. 만약 실제OrderService
에@Transactional
을 다는 걸 깜빡해도, 테스트 코드의@Transactional
때문에 테스트는 성공해버린다. 결국 운영 환경에서 터질 폭탄을 만드는 셈이다. 이 사이드 이펙트를 정확히 이해하고 조심해서 사용해야 한다.
다음 편에서는 외부 세계의 요청을 받는 Presentation Layer를 Mocking을 통해 어떻게 '순수하게' 테스트하는지 알아보겠다.
'Software Development > Test' 카테고리의 다른 글
[Test] Mock(with Mockito) (0) | 2025.07.05 |
---|---|
[Test] Presentation Layer Test(with Spring Boot) (0) | 2025.07.05 |
[Test] Persistence Layer Test(with Spring Boot, JPA) (0) | 2025.07.05 |
[Test] 테스트는 '문서'다. (0) | 2025.07.05 |
[Test] TDD(Test Driven Development) (0) | 2025.07.05 |