Practical Testing: 실용적인 테스트 가이드 강의 - 대시보드 | 인프런 (inflearn.com)
Practical Testing: 실용적인 테스트 가이드 | 박우빈 - 인프런
박우빈 | 이 강의를 통해 실무에서 개발하는 방식 그대로, 깔끔하고 명료한 테스트 코드를 작성할 수 있게 됩니다. 테스트 코드가 왜 필요한지, 좋은 테스트 코드란 무엇인지 궁금하신 모든 분을
www.inflearn.com
본 내용은 'Practical Testing: 실용적인 테스트 가이드' 라는 인프런 강의를 들으며 내 생각을 정리한 내용입니다.
Spring&JPA 기반 테스트
Layer Architecture
레이어드 아키텍쳐란, 아래와 같이 관심사를 3가지(혹은 4가지) 로 분류한 아키텍쳐를 의미한다.
테스트는 각 레이어마다 분리해서 진행한다.
Persistance Layer 테스트
비즈니스 가공 로직이 포함되어서는 안된다. 데이터에 대한 CRUD에만 집중한 레이어
@DataJpaTest
class StockRepositoryTest {
@Autowired
private StockRepository stockRepository;
@DisplayName("상품번호 리스트로 재고를 조회한다.")
@Test
void findAllByProductNumberIn() {
//given
Stock stock1 = Stock.create("001", 1);
Stock stock2 = Stock.create("002", 2);
Stock stock3 = Stock.create("003", 3);
stockRepository.saveAll(List.of(stock1, stock2, stock3));
//when
List<Stock> stocks = stockRepository.findAllByProductNumberIn(List.of("001", "002"));
//then
Assertions.assertThat(stocks).hasSize(2)
.extracting("productNumber", "quantity")
.containsExactlyInAnyOrder(
Tuple.tuple("001", 1),
Tuple.tuple("002", 2)
);
}
}
@DataJpaTest
JPA 관련 어노테이션이 적용된 클래스만 스캔하여 빈에 올린다. 때문에 모든 빈들을 올리는 @SpringBootTest 보다 로드되는 시간이 빠르다는 장점이 있다.
또한, 내장된 인메모리 DB를 사용한다.
하지만 JPA 관련 어노테이션만 스캔하기 때문에, QueryDsl 같은 보조 라이브러리를 사용한다면 추가적인 설정이 필요하다.
Business Layer 테스트
@ActiveProfiles("test")
@SpringBootTest
//@Transactional
class OrderServiceTest {
@Autowired
private OrderService orderService;
@Autowired
private OrderRepository orderRepository;
@Autowired
private OrderProductRepository orderProductRepository;
@Autowired
private ProductRepository productRepository;
@Autowired
private StockRepository stockRepository;
@AfterEach
void tearDown(){
orderProductRepository.deleteAllInBatch();
productRepository.deleteAllInBatch();
orderRepository.deleteAllInBatch();
stockRepository.deleteAllInBatch();
}
@DisplayName("주문번호 리스트를 받아 주문을 생성한다.")
@Test
void createOrder() {
//given
Product product1 = creteProduct(HANDMADE,"001",1000);
Product product2 = creteProduct(HANDMADE,"002",3000);
Product product3 = creteProduct(HANDMADE,"003",5000);
productRepository.saveAll(List.of(product1,product2,product3));
OrderCreateRequest request = OrderCreateRequest.builder()
.productNumbers(List.of("001", "002"))
.build();
//when
LocalDateTime registeredDatetime = LocalDateTime.now();
OrderResponse orderResponse = orderService.createOrder(request.toServiceRequest(), 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)
);
}
...
}
tearDown 과 @Transactional, 그리고 JPA와 사용하는 @Transactional의 주의점
tearDown과 @Transactional 모두 전체 테스트를 수행할 때, 데이터가 전파되는것을 막기위한 방법이다.
강사는 여기서 tearDown과 @Transactional의 차이를 제시해주었는데,
서비스단에 @Transactional이 없으면 변경감지를 하지 못해 실제로는 실패하지만,
테스트단에 @Transactional이 있으면 변경감지를 하게 되기 때문에 테스트에서는 성공하는 케이스가 생긴다.
즉, 서비스에는 @Transactional이 없고, 테스트단에만 @Transactional이 있는 상황이 생길 수 있는데,
실제로는 버그가 발생하지만 테스트에서는 이상없이 통과하는 케이스가 발생하게 된다라는 것이다.
결과적으로 테스트에서 @Transactional을 사용 할 때에는 서비스단에 @Transactional 적용을 했는지 여부를 한번에 파악할수 없기 때문에 주의해야한다고 한다.
서비스의 반환과 레포지토리의 반환이 동일하다면, 테스트를 전부 작성해야하는가에 대해
개인적으로 테스트 코드를 작성하면서 드는 고민 중 하나가 단순히 레포지토리에서 받아온 값을 반환하는 서비스라면, 동일한 결과를 테스트하는것인데 이걸 또 작성해야할까? 라는 의문이었다.
나는 이것에 대한 대답을 다음과 같이 정리했다.
로직이 크게 없는 서비스와, 레포지토리의 테스트는 내용이 비슷하다.하지만
서비스가 발전할수록 기능이 추가되기 때문에 동일한 테스트라고 해도 작성하는것을 고민해보아야 한다.
도메인과 서비스에서 동일한 예외처리를 해야하는가에 대한 의문
아래 코드를 보면, 도메인과 서비스단에 동일한 예외(재고 부족)를 체크하고 있다. 이렇게 되면 동일한 예외를 중첩으로 체크하는데, 이유가 뭘까? 도메인에서만 예외를 터뜨려도 충분히 괜찮은 상황 아닐까?
public class Stock extends BaseEntity {
...
public int deductQuantity(int quantity) {
if(isQuantityLessThan(quantity)){
throw new IllegalArgumentException("차감할 재고 수량이 없습니다.");
}
return this.quantity-=quantity;
}
}
public class OrderService {
...
private void deductStockQuantity(List<Product> products) {
List<String> stockProductNumbers = extractStockProductNumbers(products);
Map<String, Stock> stockMap = createStockMapBy(stockProductNumbers);
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);
}
}
}
나는 답변을 다음처럼 정리했다.
클라이언트에게 주고싶은 메시지가 다르기 때문에 서비스단에서도 동일한 예외를 잡는다.
도메인에서 발생하는 예외는 범용적인 예외이고, 서비스단에서 발생하는 예외는 각 상황 맥락에 맞는 예외이기 때문에, 그에 맞는 메시지를 보내고 싶을 수 있다.
혹은 도메인 로직에서 커스텀 예외를 발생시키고, 외부에서 try-catch로 잡아 다시 원하는 예외 메시지를 전파하는 방법도 있다.
서비스단의 @Transactional(readOnly = true) - JPA 성능상 이점과 CQRS에 관하여
@Transactional(readOnly = true)가 되면 읽기 전용 즉, CRUD에서 R(조회) 만 동작하게 된다. 이렇게 되면 JPA에서 동작하는 변경감지같은 기능들이 동작할 필요가 없게되어 JPA 성능에 이점이 생긴다.
CQRS란, command(업데이트) 와 query(읽기) 를 분리하는 패턴을 의미한다.
만약 서비스 자체를 읽기전용과 업데이트로 분리하게 된다면,
읽기전용 서비스는 슬레이브db, 업데이트 서비스는 마스터db에 배정하는 식으로 관리가 수월해진다.
Presentation Layer 테스트
파라미터에 대한 최소한의 검증을 수행
@WebMvcTest(controllers = ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private ProductService productService;
...
@DisplayName("판매 상품을 조회한다.")
@Test
void getSellingProducts() throws Exception {
//given
List<ProductResponse> responses = List.of();
Mockito.when(productService.getSellingProducts()).thenReturn(responses);
//when //then
mockMvc.perform(
get("/api/v1/products/selling")
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.status").value("OK"))
.andExpect(jsonPath("$.message").value("OK"))
.andExpect(jsonPath("$.data").isArray());
}
}
@WebMvcTest, MockMvc, @MockBean
@WebMvcTest : mvc에 관해서만 슬라이스 테스트할 수 있는 어노테이션. @component, @Service 같은 빈들은 자동구성되지 않는다. 또한 MockMvc, SpringSecurity 도 자동 구성한다. 일반적으로 @MockBean과 함께 사용한다.
MockMvc : Mock 객체를 사용해 스프링 mvc 동작을 재현할 수 있는 스프링 테스트 프레임워크
@MockBean : Mock 객체를 생성하여 빈에 올린다. 이후 Mockito를 이용해 조건과 결과를 설정할 수 있다.
Mock 객체를 사용하는 이유
테스트를 하기위해서 준비해야할 것들(객체를 생성하고, 저장하고, 서비스를 시행하고, 등등)이 너무 많아지기 때문에 잘 동작하는것을 가정하고 테스트를 하고자 Mock(가짜) 객체를 사용한다.
하위 레이어가 상위 레이어를 모르는것이 가장 좋다.
컨트롤러 레이어 하위 dto를 서비스단에 그대로 넘기게 된다면,
하위레이어가 상위 레이어의 정보를 알게 되므로(의존관계 생성),
각각의 레이어를 분리해서 관리하고자 할 때 어려움이 생긴다.
컨트롤러에서 서비스로 전송할 때에는, 서비스 레이어 하위 dto로 받아 넘기는것이 좋다.
@RestController
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@PostMapping("/api/v1/products/new")
public ApiResponse<ProductResponse> createProduct(@Valid @RequestBody ProductCreateRequest request) {
return ApiResponse.ok(productService.createProduct(request.toServiceRequest()));
}
...
}
@Getter
@NoArgsConstructor
public class ProductCreateRequest {
...
public ProductCreateServiceRequest toServiceRequest() {
return ProductCreateServiceRequest.builder()
.type(type)
.sellingStatus(sellingStatus)
.name(name)
.price(price)
.build();
}
}
@Transactional(readOnly = true)
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
@Transactional
public ProductResponse createProduct(ProductCreateServiceRequest request) {
String nextProductNumber = createNextProductNumber();
Product product = request.toEntity(nextProductNumber);
Product savedProduct = productRepository.save(product);
return ProductResponse.of(savedProduct);
}
...
}
@Valid 를 이용한 파라미터 검증
@NotNull 등과 같은 검증 어노테이션으로 파라미터를 검증할 수 있다.
@Getter
@NoArgsConstructor
public class ProductCreateRequest {
@NotNull(message = "상품 타입은 필수입니다.")
private ProductType type;
@NotNull(message = "상품 판매상태는 필수입니다.")
private ProductSellingStatus sellingStatus;
@NotBlank(message = "상품 이름은 필수입니다.")
private String name;
@Positive(message = "상품 가격은 양수여야 합니다.")
private int price;
...
}
@RestController
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@PostMapping("/api/v1/products/new")
public ApiResponse<ProductResponse> createProduct(@Valid @RequestBody ProductCreateRequest request) {
return ApiResponse.ok(productService.createProduct(request.toServiceRequest()));
}
..
}
@WebMvcTest(controllers = ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private ProductService productService;
...
@DisplayName("신규 상품을 등록할 때 상풉 타입은 필수값이다.")
@Test
void createProductWithOutType() throws Exception {
//given
ProductCreateRequest request = ProductCreateRequest.builder()
.sellingStatus(ProductSellingStatus.SELLING)
.name("아메리카노")
.price(4000)
.build();
//when //then
mockMvc.perform(post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("400"))
.andExpect(jsonPath("$.status").value("BAD_REQUEST"))
.andExpect(jsonPath("$.message").value("상품 타입은 필수입니다."))
.andExpect(jsonPath("$.data").isEmpty());
}
...
}
마치며
이번 섹션을 공부하면서, 지난날의 내가 테스트 코드를 짤 때, 짤 필요가 있는 코드와 없는 코드를 과연 구분했었는가에 대해서 고민을 하게 되었다. 또, TDD에 대해서 찾아보니 TDD에 대한 비판적인 시각도 꽤 많다는것을 알게 되었고, 그 부분도 이해가 되는 부분들이였다. 덕분에 무조건 적으로 받아들이는것이 아닌 비판적인 시각도 키워야겠다는 생각도 하게 되었다. 하지만 각자 장 단점이 존재 할 뿐 좋고 나쁘다는 없다고 생각하기 때문에 받아들이지 않을 필요도 없다고 생각한다.
참고
https://mangkyu.tistory.com/242
[Spring] 스프링부트 테스트를 위한 의존성과 어노테이션, 애플리케이션 컨택스트 캐싱(@SpringBootTes
스프링부트에서 테스트를 작성하기 위한 다양한 어노테이션(@SpringBootTest, @WebMvcTest, @DataJpaTest)들을 알아보도록 하겠습니다. 실제 테스트를 작성하는 방법은 이 포스팅을 참고해주세요. 1. 스프링
mangkyu.tistory.com
https://mson-it.tistory.com/16
[Spring] @SpringBootTest vs @DataJpaTest
테스트 코드를 작성하고 새로 작성된 테스트 코드를 돌려볼 때 실행 시간이 너무 오래 걸렸다. 이를 기존에 @SpringBootTest로 작성된 코드를 @DataJpaTest 변경하면서 해결할 수 있었는 데 변경하는 중
mson-it.tistory.com
https://jojoldu.tistory.com/320
@SpyBean @MockBean 의도적으로 사용하지 않기
보통 스프링 부트 관련 테스트 코드를 작성할때 @MockBean과 @SpyBean 를 사용했습니다. (참고: SpringBoot @MockBean, @SpyBean 소개) 복잡한 스프링 프로젝트에서도 원하는 코드만 아주 간단하게 Mock 처리를
jojoldu.tistory.com
Spring Boot Reference Documentation
This section goes into more detail about how you should use Spring Boot. It covers topics such as build systems, auto-configuration, and how to run your applications. We also cover some Spring Boot best practices. Although there is nothing particularly spe
docs.spring.io
'Java > 테스트 코드' 카테고리의 다른 글
Practical Testing: 실용적인 테스트 가이드 (1일차) (0) | 2024.03.29 |
---|