Presentation layer- Mockito/MockMvc/Serialization/jakarta.validation
✅ Presentation Layer
- check parameter from front end
✅ MockMVC
- use Mock(fake data) to test MVC
To test controller, make
service and repositorya mock data- How to use?
- need two annotations
@WebMvcTest(controllers = ProductController.class)@Autowired private MockMvc mockMvc;@MockBean- 👉🏻 use
@MockitoBean
1
2
3
4
5
6
7
8
9
10
11
12
13
@WebMvcTest(controllers = ProductController.class)
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper; //help Serialization, deserialization of objects
@MockitoBean //make service mock
private ProductService productService;
}
1️⃣ Post Mapping
📌 Production code: check request
1
2
3
4
5
6
7
8
9
@RequiredArgsConstructor
@RestController
public class ProductController {
private final ProductService productService;
@PostMapping("/api/v1/products/new")
public ProductResponse createProduct(@RequestBody ProductCreateRequest request){
return productService.createProduct(request);
}
}
- 🌙 For serialization
- In request class, need
@NoArgsConstructor
1
2
3
4
5
6
7
8
@Getter
@NoArgsConstructor //need for serialization, deserialization from ObjectMapper
public class ProductCreateRequest {
private ProductType type;
private ProductSellingStatus sellingStatus;
private String name;
private int price;
- For base entity JPA auditing
- create a distinct
JpaAuditingConfigclass - and disable
@EnableJpaAuditingin application
1
2
3
4
5
6
7
8
9
10
11
12
13
@EnableJpaAuditing
@Configuration
public class JpaAuditingConfig {
}
//@EnableJpaAuditing //for base entity, disable and create distinct JpaAuditingConfig
@SpringBootApplication
public class CafeTddApplication {
public static void main(String[] args) {
SpringApplication.run(CafeTddApplication.class, args);
}
}
📌 Test code: check request
- MockMVC
- Object Mapper
- test must include
@MockitoBean - should mock service to test controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@WebMvcTest(controllers = ProductController.class) // ⭐️ MockMVC
class ProductControllerTest {
@Autowired
private MockMvc mockMvc; //⭐️ MockMVC
@Autowired
private ObjectMapper objectMapper; //🌙 help Serialization, deserialization of objects
@MockitoBean //test must include @MockitoBean
private ProductService productService; //⭐️ MockMVC
@DisplayName("Create product in controller")
@Test
void createProduct() throws Exception {
//given
ProductCreateRequest request = ProductCreateRequest.builder()
.type(HANDMADE)
.sellingStatus(SELLING)
.name("americano")
.price(4000)
.build();
//when //then
mockMvc.perform(
MockMvcRequestBuilders.post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request)) //🌙serliazize request
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(MockMvcResultHandlers.print()) //see logs
.andExpect(MockMvcResultMatchers.status().isOk());
}
}
✅ Wrap Response in ApiResponse
✔️ API response
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Getter //can create json based on 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<T>(httpStatus, message, data);
}
public static <T> ApiResponse<T> of(HttpStatus httpStatus, T data) {
return of(httpStatus, httpStatus.name(), data);
}
//response for creating OK
public static <T> ApiResponse<T> ok(T data) {
return ApiResponse.of(HttpStatus.OK, HttpStatus.OK.name(), data);
}
}
✔️ ApiControllerAdvice
- use
@RestControllerAdvice
1
2
3
4
5
6
7
8
9
10
11
12
@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);
}
}
✔️ Product controller
- add
@Validto@RequestBody
1
2
3
4
5
@PostMapping("/api/v1/products/new")
//add @Valid to @RequestBody
public ApiResponse<ProductResponse> createProduct(@Valid @RequestBody ProductCreateRequest request){
return ApiResponse.ok(productService.createProduct(request));
}
📌 Production code for validating parameter
- use jakarta.validation
- add dependencies
1
implementation 'org.springframework.boot:spring-boot-starter-validation'
✔️ Request DTO
- In request class, add
@NotNull,@NotBlank,@Positive
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Getter
@NoArgsConstructor //need for serialization, deserialization from ObjectMapper
public class ProductCreateRequest {
@NotNull(message = "Product type cannot be null")
private ProductType type;
@NotNull(message = "Product status cannot be null")
private ProductSellingStatus sellingStatus;
@NotBlank(message = "Product name cannot be blank")
private String name;
@Positive(message = "Product price should be positive")
private int price;
}
- 🆚 Difference between
@NotNull,@NotBlank,@NotEmpty @NotNull: cannot be null 👀""," "will pass@NotEmpty: cannot be empty 👀" "will pass@NotBlank: if you do not want""nor" ", use@NotBlank- recommended for String validation
❓ What if I want name to be length max 20?
- this specific validation should not be done in controller
- but should be dont in service layer
or in constructor, when creating the product
- 👉🏻 controller should have the responsibility of http requests, responses
📌 Test code for validating the parameters
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@DisplayName("When creating product, product type cannot be null")
@Test
void createProductWithoutType() throws Exception {
//given
ProductCreateRequest request = ProductCreateRequest.builder()
.sellingStatus(SELLING)
.name("americano")
.price(4000)
.build();
//when //then
mockMvc.perform(
post("/api/v1/products/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print()) //see logs
.andExpect(status().isBadRequest()) //check status, code, message...
.andExpect(jsonPath("$.code").value(400))
.andExpect(jsonPath("$.status").value("400 BAD_REQUEST"))
.andExpect(jsonPath("$.message").value("Product type cannot be null"))
.andExpect(jsonPath("$.data").isEmpty());
}
- can also validate for the rest of attributes
@NotNull sellingStatus@NotBlank name@Positive price
2️⃣ Get Mapping
📌 Production code for GET
1
2
3
4
5
6
7
8
9
10
@RequiredArgsConstructor
@RestController
public class ProductController {
private final ProductService productService;
@GetMapping("/api/v1/products/selling")
public ApiResponse<List<ProductResponse>> getSellingProducts(){
return ApiResponse.ok(productService.getSellingProducts());
}
📌 Test code for GET
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@DisplayName("Get selling products")
@Test
void getSellingProducts() throws Exception {
//given
List<ProductResponse> result = List.of();
when(productService.getSellingProducts()).thenReturn(result);
//when //then
mockMvc.perform(
get("/api/v1/products/selling")
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(200))
.andExpect(jsonPath("$.status").value("200 OK"))
.andExpect(jsonPath("$.message").value("OK"))
.andExpect(jsonPath("$.data").isArray()); //just check if result is Array
}
✅ Refactoring: create separate DTO for service
- now, service and controller is using the same DTO
CreateOrderRequest - however, service should not know what controller has
👎🏻 before
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
//controller
@RequiredArgsConstructor
@RestController
public class OrderController {
private final OrderService orderService;
@PostMapping("/api/v1/orders/new")
public ApiResponse<OrderResponse> createOrder(@Valid @RequestBody OrderCreateRequest request ){ //controller recieve request
LocalDateTime registerDateTime = LocalDateTime.now();
return ApiResponse.ok( orderService.createOrder(request, registerDateTime)); //services uses the same request
}
}
//service
//recieves the same request DTO as param
public OrderResponse createOrder(OrderCreateRequest request, LocalDateTime registeredDateTime){
List<String> productNumbers = request.getProductNumbers();
List<Product> duplicateProducts = findProductsBy(productNumbers);
deductStockQuantities(duplicateProducts);
Order order = Order.create(duplicateProducts, registeredDateTime);
Order savedOrder = orderRepository.save(order);
return OrderResponse.of(savedOrder);
}
👍🏻 after
- create a separate DTO for service code,
called
CreateOrderServiceRequestCreateOrderServiceRequestlooks identical toCreateOrderRequest- In
CreateOrderRequest, addtoServiceRequest()method - so controller can convert
CreateOrderRequestintoCreateOrderServiceRequest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//CreateOrderRequest
@Getter
@NoArgsConstructor
public class OrderCreateRequest {
@NotEmpty(message = "ProductNumberList cannot be empty")
private List<String> productNumbers;
@Builder
public OrderCreateRequest(List<String> productNumbers) {
this.productNumbers = productNumbers;
}
//method toServiceRequest()
public OrderCreateServiceRequest toServiceRequest() {
return OrderCreateServiceRequest.builder()
.productNumbers(productNumbers)
.build();
}
}
//CreateOrderServiceRequest
@Getter
@NoArgsConstructor
public class OrderCreateServiceRequest {
private List<String> productNumbers;
@Builder
public OrderCreateServiceRequest(List<String> productNumbers) {
this.productNumbers = productNumbers;
}
}
- now in controller,
- when calling service, hand over the
CreateOrderServiceRequest
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
//controller
@RequiredArgsConstructor
@RestController
public class OrderController {
private final OrderService orderService;
@PostMapping("/api/v1/orders/new")
public ApiResponse<OrderResponse> createOrder(@Valid @RequestBody OrderCreateRequest request ){
LocalDateTime registerDateTime = LocalDateTime.now();
//convert to CreateOrderServiceRequest
return ApiResponse.ok( orderService.createOrder(request.toServiceRequest(), registerDateTime));
}
}
//service
//recieve OrderCreateServiceRequest as parameter
public OrderResponse createOrder(OrderCreateServiceRequest request, LocalDateTime registeredDateTime){
List<String> productNumbers = request.getProductNumbers();
List<Product> duplicateProducts = findProductsBy(productNumbers);
//filter, check stock for BAKERY or BOTTLE, and decrease stock quantity
deductStockQuantities(duplicateProducts);
Order order = Order.create(duplicateProducts, registeredDateTime);
Order savedOrder = orderRepository.save(order);
return OrderResponse.of(savedOrder);
}
This post is licensed under CC BY 4.0 by the author.