사용자의 요청을 먼저 받고 Controller 가 있는 프레젠테이션 계층의 단위 테스트에 대해 알아보자. 그리고 mocking 처리 개념과 validation 에 대한 처리에 대해서도 알아본다.
6.1. Presentation Layer 특징
프레젠테이션 계층은 클라이언트로 부터 요청을 잘 받는지, 받아야 할 데이터는 모두 정확히 들어왔는지 확인이 필요하다. 이러한 프레젠테이션 계층 테스트는 단위 테스트 성격을 띤다.
프레젠테이션 계층에도 서비스 계층의 요소 등이 있을 것이다. 그럼 다른 계층에 의존성을 가지는데 어떻게 단위 테스트 처럼 테스트를 진행할 수 있을까? 바로 의존성을 가지는 다른 계층의 부분을 @MockBean 를 활용해 가짜로 타계층의 요소를 만들어 테스트를 진행하는 방식이다.
6.2. Mockito 라이브러리와 MockMvc 프레임워크
mock 단어의 한글 뜻은 “가짜의, 모조품” 이다. Mockito 라이브러리는 특정 부분만 테스트 하고 싶을 때, 관심 없는 객체의 생성 시간이나 자원 소모 등 비용이 불필요하게 발생해야 할 때, 정상적으로 작동하는 것 처럼 보이는 객체를 가짜로 생성하여, 관심 대상에 좀 더 집중할 수 있는 여러 기능을 제공한다. MockMvc 는 스프링 MVC 동작을 재현할 수 있는 테스트 프레임워크이다.
프레젠테이션 계층 테스트에 이를 적용하여, 프레젠테이션 계층 테스트할 때 의존관계에 있는 다른 계층의 요소들은 가짜로 만들어 클라이언트의 요청에 대한 테스트에 집중하고자 한다.
6.3. @Valid 를 적용한 컨트롤러 작성
valid 애노테이션 사용한 상품 컨트롤러 작성
요청받은 파라미터의 유효성을 검사하기위해 의존성을 추가해야한다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
maven 또는 build.gradle
dependencies {
// ...
implementation 'org.springframework.boot:spring-boot-starter-validation'
}
@Valid 을 사용한 상품 Controller 를 작성한 프로덕션 코드이다.
package msa.tc.product.controller;
import lombok.RequiredArgsConstructor;
import msa.tc.common.ApiResponse;
import msa.tc.product.dto.ProductRequest;
import msa.tc.product.dto.ProductResponse;
import msa.tc.product.service.ProductService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
@RequiredArgsConstructor
@RestController
public class ProductController {
private final ProductService productService;
@PostMapping("/api/v1/products/new")
public ApiResponse<ProductResponse> createProduct(@Valid @RequestBody ProductRequest request) {
return ApiResponse.ok(productService.createProduct(request.toServiceVo()));
}
}
ApiResponse.java: 공통 응답 객체
package msa.tc.common;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public class ApiResponse<T> {
private int code;
private HttpStatus status;
private String message;
private T data;
public ApiResponse(HttpStatus status, String message, T data) {
this.code = status.value();
this.status = status;
this.message = message;
this.data = data;
}
public static <T> ApiResponse<T> of (HttpStatus httpStatus, String message, T data) {
return new ApiResponse<>(httpStatus, message, data);
}
public static <T> ApiResponse<T> of (HttpStatus httpStatus, T data) {
return of(httpStatus, httpStatus.name(), data);
}
public static <T> ApiResponse<T> ok (T data) {
return of(HttpStatus.OK, data);
}
}
필요시 각 필드별 제약조건 걸기
javax.validation.constraints.NotBlank;
javax.validation.constraints.NotNull;
javax.validation.constraints.Positive;
package simple.testcode.product.dto;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import simple.testcode.product.domain.ProductSellingStatus;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Positive;
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProductRequest {
@NotNull(message = "상품 판매상태는 필수입니다.")
private ProductSellingStatus sellingStatus;
@NotBlank(message = "상품 이름은 필수입니다.")
private String name;
@Positive(message = "상품 가격은 양수여야 합니다.")
private int price;
@Builder
private ProductRequest(ProductSellingStatus sellingStatus, String name, int price) {
this.sellingStatus = sellingStatus;
this.name = name;
this.price = price;
}
/**
* controller >> service dto convert
*/
public ProductServiceVo toServiceVo() {
return ProductServiceVo.builder()
.sellingStatus(sellingStatus)
.name(name)
.price(price)
.build();
}
}
@Valid, javax.validation.constraints.* 에서 설정한 제약조건에 위배된 인자값이 들어오면 어떻게 될까? 아래 6.4.2. 에서 다룬다.
6.4. 기본적인 Presentation Layer Test
Presentation 계층에서는 Controller 관련 Bean 들만 스캔하여 등록 후 제공해주는 애노테이션인 스프링 부트 테스트의 @WebMvcTest 를 사용해서 좀 더 가볍게 테스트를 진행할 수 있다. 서비스 계층 하위는 mocking 처리, 즉 정상 작동 하는 척 하는 가짜 객체를 만들어 대체할 것이다.
6.4.1. MockMvc과 @MockBean을 활용한 Presentation Layer Test
상품 컨트롤러 테스트 작성
ProductController 에서 보면 주입받는 빈인 ProductService 는 상품컨트롤러 작동시 필요한 요소여서 테스트할 때에도 필요하다. Mockito 에서 제공하는 @MockBean 을 활용하여 productService 에 스프링이 제공하는 서비스 빈 대신 mock 객체로 대체한다(30~31 line). MockMvc 또한 주입받았다(24 line).
package simple.testcode.product.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import simple.testcode.product.domain.ProductSellingStatus;
import simple.testcode.product.dto.ProductRequest;
import simple.testcode.product.service.ProductService;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(controllers = ProductController.class)
class ProductControllerTest {
// service layer 하위는 mocking 처리
@Autowired
private MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper; // post body 의 직렬화 <> 역직렬화 위함
@MockBean // mockito 로 만든 mock 객체를 productService 에 넣어줌
private ProductService productService;
@DisplayName("신규 상품을 등록 요청을 받아 OK로 응답한다.")
@Test
void createProduct() throws Exception {
// given
ProductRequest request = ProductRequest.builder()
.sellingStatus(ProductSellingStatus.SELLING)
.name("아메리카노a")
.price(4100)
.build();
// when && // then
mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print()) // log 기록
.andExpect(status().isOk());
}
}
‘6_4_1_신규 상품을 등록 요청을 받아 OK로 응답한다’ 로그를 보면 200의 OK 상태값이 응답온다는 것을 알 수 있다.
6.4.2. @RestControllerAdvice 를 활용한 Presentation Layer Test
9ec2ce15 6. Presentation Layer 테스트 작성 - 4.2. ControllerAdvice 빈 설정 & 상품 컨트롤러 파라미터 validation 검사
@ExceptionHandler 같은 경우는 컨트롤러 내에서 발생하는 예외를 잡아서 하나의 메서드에서 처리해주는 기능을 한다. @ExceptionHandler가 클래스별 설정하고, 해당 클래스에 대한 것이라면, @RestControllerAdvice는 전역 컨트롤러에서 발생할 수 있는 예외를 잡아 처리해주는 애노테이션이다. https://tecoble.techcourse.co.kr/post/2023-05-03-ExceptionHandler-ControllerAdvice/
package msa.tc.common;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class ApiControllerAdvice {
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ExceptionHandler(BindException.class)
public ApiResponse<Object> bindException(BindException e) {
return ApiResponse.of(
HttpStatus.BAD_REQUEST,
e.getBindingResult().getAllErrors().get(0).getDefaultMessage(),
null
);
}
}
상품 컨트롤러의 파라미터 ProductRequest 의 유효성 검사 테스트를 작성하자.
// 파라미터 유효성 검사 테스트
@DisplayName("신규 상품을 등록할 때, 상품 타입은 필수값이다.")
@Test
void createProductWithoutType() throws Exception {
// given
ProductRequest request = ProductRequest.builder()
// .sellingStatus(ProductSellingStatus.SELLING) // 상품 판매상태 를 요청 파라미터에 넣지 않음
.name("아메리카노")
.price(4000)
.build();
// when && // then
mockMvc.perform(MockMvcRequestBuilders.post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
// 간단 버전
// .andExpect(status().isBadRequest());
// 상세 버전
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value("400"))
.andExpect(jsonPath("$.status").value("BAD_REQUEST"))
.andExpect(jsonPath("$.message").value("상품 판매상태는 필수입니다."))
.andExpect(jsonPath("$.data").isEmpty());
}
로그(6_4_1_신규 상품을 등록 요청을 받아 OK로 응답한다.log)를 보면 ControllerAdvice에서 설정한대로 예외에 대한 응답이 온 것을 알 수 있다.
MockHttpServletResponse:
Status = 400
Error message = null
Headers = [Content-Type:"application/json"]
Content type = application/json
Body = {"code":400,"status":"BAD_REQUEST","message":"상품 판매상태는 필수입니다.","data":null}
Forwarded URL = null
Redirected URL = null
Cookies = []
6.5. Presentation Layer Test 예제
신규 주문 등록 예제, parameter valid check 예제
package simple.testcode.order.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import simple.testcode.common.controller.ApiResponse;
import simple.testcode.order.dto.OrderRequest;
import simple.testcode.order.dto.OrderResponse;
import simple.testcode.order.service.OrderService;
import javax.validation.Valid;
import java.time.LocalDateTime;
@RequiredArgsConstructor
@RestController
public class OrderController {
private final OrderService orderService;
@PostMapping("/api/v1/orders/new")
public ApiResponse<OrderResponse> createOrder(@Valid @RequestBody OrderRequest request) {
LocalDateTime registeredDateTime = LocalDateTime.now();
return ApiResponse.ok(orderService.createOrder(request.toServiceVo(), registeredDateTime));
}
}
package simple.testcode.order.dto;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import javax.validation.constraints.NotEmpty;
import java.util.List;
@Getter
// com.fasterxml.jackson.databind.exc.InvalidDefinitionException 방지 위함
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderRequest {
@NotEmpty(message = "상품 번호 리스틑 필수입니다.")
private List<String> productNumbers;
@Builder
private OrderRequest(List<String> productNumbers) {
this.productNumbers = productNumbers;
}
public OrderServiceVo toServiceVo() {
return OrderServiceVo.builder()
.productNumbers(productNumbers)
.build();
}
}
package simple.testcode.order.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import simple.testcode.order.dto.OrderResponse;
import simple.testcode.order.dto.OrderServiceVo;
import simple.testcode.order.service.OrderService;
import java.time.LocalDateTime;
import java.util.List;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(controllers = OrderController.class)
class OrderControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@MockBean
OrderService orderService;
@DisplayName("신규 주문을 등록한다.")
@Test
void createOrder() throws Exception {
// given
OrderServiceVo request = OrderServiceVo.builder()
.productNumbers(List.of("001"))
.build();
OrderResponse result = OrderResponse.builder().build();
Mockito.when(orderService.createOrder(request, LocalDateTime.now())).thenReturn(result);
// when && // then
mockMvc.perform(
post("/api/v1/orders/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
// simple
// .andExpect(status().isOk());
// validation
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.status").value("OK"))
.andExpect(jsonPath("$.message").value("OK"));
}
@DisplayName("경계값, 400 테스트, 신규 주문을 등록할 때 상품번호는 1개 이상이어야 한다.")
@Test
void createOrderWithEmptyProductNumbers() throws Exception {
// given
OrderServiceVo request = OrderServiceVo.builder()
.productNumbers(List.of())
.build();
// when && // then
mockMvc.perform(
post("/api/v1/orders/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.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());
}
}
'테스트코드 > 작성방법' 카테고리의 다른 글
7. End to End 테스트 (0) | 2024.03.26 |
---|---|
5. Business Layer Test (1) | 2024.03.26 |
4. 외부 시스템과의 연계 테스트 (1) | 2024.03.26 |
3. Persistence Layer Test (0) | 2024.03.26 |
2. Layered Architecture (0) | 2024.03.26 |