나의 개발일지
[인프런 워밍업 클럽 3기](Day 16 미션) 레이어별 특징 및 테스트 방법 본문
미션 설명
Layered Architecture 구조의 레이어별 테스트 작성법을 알아보았습니다.
레이어별로 1) 어떤 특징이 있고, 2) 어떻게 테스트를 하면 좋을지, 자기만의 언어로 다시 한번 정리해 볼까요?
나의 답
Persistence Layer
- 특징
- DB 데이터에 직접 접근하는 역할을 가진다
- 데이터 제공 외의 역할을 해선 안된다 (비즈니스 가공 로직은 포함해선 안된다)
- 데이터에 대한 CRUD에 집중한 레이어다
- 테스트 코드 작성법
- DB 접근을 통해 수행하는 CRUD 작업이 정상적으로 수행됐는지 검증한다
- DataJpaTest 어노테이션을 활용하여 좀 더 가벼운(더 빠른) 테스트 코드 작성이 가능하다
- Persistence Layer와 관련된 빈들만을 스프링 컨테이너에 올린다
- 테스트 시 사용하는 DB는 실제 배포 환경에서 사용하는 DB를 일치시켜야 한다
- 두 환경의 DB를 다르게 할 경우, 코드의 안정성을 보장할 수 없다
- @ActiveProfiles("test") 어노테이션 application.yml 파일에 설정해둔 테스트 환경 전용 설정을 사용할 수 있다.
코드 예시
@ActiveProfiles("test")
@DataJpaTest
class ProductRepositoryTest {
@Autowired
private ProductRepository productRepository;
@DisplayName("원하는 판매상태를 가진 상품들을 조회한다.")
@Test
void findAllBySellingStatusIn() {
// given
Product product1 = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
Product product2 = createProduct("002", HANDMADE, HOLD, "카페라떼", 4500);
Product product3 = createProduct("003", HANDMADE, STOP_SELLING, "팥빙수", 7000);
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)
);
}
private Product createProduct(String productNumber, ProductType type, ProductSellingStatus sellingStatus, String name, int price) {
return Product.builder()
.productNumber(productNumber)
.type(type)
.sellingStatus(sellingStatus)
.name(name)
.price(price)
.build();
}
}
application.yml 파일 예시
spring:
application:
name: cafekiosk
profiles:
default: local
datasource:
url: jdbc:h2:mem:~/cafeKioskApplication
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: none
---
spring:
config:
activate:
on-profile: local
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
defer-datasource-initialization: true # (2.5~) Hibernate 초기화 이후 data.sql 실행
h2:
console:
enabled: true
---
spring:
config:
activate:
on-profile: test
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
sql:
init:
mode: NEVER
Business Layer
- 특징
- 비즈니스 로직을 구현하는 역할을 가진다
- Persistence Layer와의 상호 작용을 통해 비즈니스 로직을 전개한다
- 트랜잭션을 보장해야 한다
- 테스트 코드 작성법
- 비즈니스 로직의 정상 동작을 테스트한다.
- 엣지 케이스 위주로 작성한다
- 테스트에 성공하는 특정 값의 범위가 있을 경우, 해당 범위의 극점을 사용함으로써 나머지 값들의 성공을 어느정도 보장할 수 있다
- 해피 케이스 외의 눈에 보이는 예외와 눈에 보이지 않는 예외 케이스에 대한 테스트에 집착해야 한다.
- Persistence Layer와 함께 통합 테스트로 진행한다
tearDown() vs @Transactional
각 테스트는 독립성이 보장되어야 한다. 이를 위해 테스트를 수행할때마다 초기화 작업이 진행되어야 하며 이때 두가지 방식을 사용할 수 있다.
- tearDown()
- @AfterEach 메소드를 붙인 tearDown() 함수를 설정하여 직접 초기화 로직을 작성한다
- tearDown이라는 명칭은 단순 관례에 불과하며, 이는 테스트 코드 수행 후 정리하는 함수라는 의미를 가진다
- @Transactional
- 테스트 클래스 혹은 테스트 메소드에 어노테이션 형식으로 사용할 수 있다
- 테스트가 끝날때마다 트랜잭션의 롤백 기능을 활용하여 초기화 작업을 수행한다
- 실제 코드의 안정성을 보장할 수 없기 때문에 조심스럽게 활용해야 한다
- 실제 코드의 @Transactional 누락 여부와 관계 없이 @Transactional 어노테이션이 작동한다.
- 테스트가 실제 코드의 @Transactional 누락을 잡아낼 수 없다!
코드 예시
@ActiveProfiles("test")
//@Transactional
@SpringBootTest
class OrderServiceTest {
@Autowired
private ProductRepository productRepository;
@Autowired
private OrderRepository orderRepository;
@Autowired
private OrderProductRepository orderProductRepository;
@Autowired
private StockRepository stockRepository;
@Autowired
private OrderService orderService;
@AfterEach
void tearDown() {
orderProductRepository.deleteAllInBatch();
productRepository.deleteAllInBatch();
orderRepository.deleteAllInBatch();
stockRepository.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));
OrderCreateServiceRequest request = OrderCreateServiceRequest.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 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", 1);
Stock stock2 = Stock.create("002", 2);
stockRepository.saveAll(List.of(stock1, stock2));
OrderCreateServiceRequest request = OrderCreateServiceRequest.builder()
.productNumbers(List.of("001", "001", "002", "003"))
.build();
// when // then
assertThatThrownBy(() -> orderService.createOrder(request, registeredDateTime))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("재고가 부족한 상품이 있습니다.");
}
private Product createProduct(ProductType type, String productNumber, int price) {
return Product.builder()
.type(type)
.productNumber(productNumber)
.price(price)
.sellingStatus(SELLING)
.name("메뉴 이름")
.build();
}
}
Presentation Layer
- 특징
- 외부 세계의 요청을 가장 먼저 받는 계층이다
- 파라미터에 대한 최소한의 유효성 검사를 수행한다
- 테스트 코드 작성법
- 비즈니스 로직을 제외한, 외부 요청에 대한 유효성 검사와 응답에 대한 테스트를 진행한다
- Business Layer 호출은 Mock 객체를 통해 이루어진다
- MockMvc 인스턴스를 통한 모의 요청을 사용한다
- 이때 json 형식의 body가 필요할 경우 ObjectMapper를 활용한다.
- 비즈니스 로직을 제외한, 외부 요청에 대한 유효성 검사와 응답에 대한 테스트를 진행한다
코드 예시
@WebMvcTest(controllers = OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private OrderService orderService;
@DisplayName("신규 주문을 등록한다.")
@Test
void createOrder() throws Exception {
// given
OrderCreateRequest request = OrderCreateRequest.builder()
.productNumbers(List.of("001"))
.build();
// when // then
mockMvc.perform(
MockMvcRequestBuilders.post("/api/v1/orders/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.status").value("OK"))
.andExpect(jsonPath("$.message").value("OK"));
}
@DisplayName("신규 주문을 등록할 때 상품번호는 1개 이상이어야 한다.")
@Test
void createOrderWithEmptyProductNumbers() throws Exception {
// given
OrderCreateRequest request = OrderCreateRequest.builder()
.productNumbers(List.of())
.build();
// when // then
mockMvc.perform(
MockMvcRequestBuilders.post("/api/v1/orders/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(MockMvcResultHandlers.print())
.andExpect(MockMvcResultMatchers.status().isBadRequest())
.andExpect(jsonPath("$.code").value("400"))
.andExpect(jsonPath("$.status").value("BAD_REQUEST"))
.andExpect(jsonPath("$.message").value("상품 번호 리스트는 필수입니다."));
}
}
'스터디 > 인프런 워밍업 클럽 백엔드 코드 3기' 카테고리의 다른 글
| [인프런 워밍업 클럽 3기](Day 18 미션) 테스트 코드 (0) | 2025.03.26 |
|---|---|
| [인프런 워밍업 클럽 3기](Day 4 미션) 리팩토링하기, SOLID 설명하기 (0) | 2025.03.06 |
| [인프런 워밍업 클럽 3기](Day 2 미션) 추상과 구체의 예시를 적어보기 (0) | 2025.03.04 |