When I got used to JUnit4 too much and decided to write in JUnit5, I had to investigate quite a bit. Now, I will summarize the frequently used implementation samples for myself.
In addition, the Web application to be tested assumes Rest API and does not include the View layer test.
--Controller test Make a pseudo request with MockMVC and test Filter + Controller + ExceptionHandler
--Testing with Mock --Mock the bean and test it --Mock one to DI and the other to Mock for testing
--Parameterization test Repeat the same case with only the test data changed
First, create an app to be tested. As usual, Created by adding lombok, Spring Web, Validation to dependencies in GradleProject with spring initializr .RELEASE&packaging=jar&jvmVersion=11&groupId=com.example&artifactId=demo&name=demo&description=Demo%20project%20for%20Spring%20Boot&packageName=com.example.demo&dependencies=lombok,web,validation).
build.gradle
plugins {
id 'org.springframework.boot' version '2.3.4.RELEASE'
id 'io.spring.dependency-management' version '1.0.10.RELEASE'
id 'java'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation('org.springframework.boot:spring-boot-starter-test') {
exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
}
testCompileOnly 'org.projectlombok:lombok' //add to
testAnnotationProcessor 'org.projectlombok:lombok' //add to
}
test {
useJUnitPlatform()
}
For the generated build.gradle
, two lines have been added to dependency
so that lombok
can be used in the test code. (See comments above)
We will create the following classes for this project.
Create two Service
s called from Controller
.
First of all, it performs simple internal processing.
DemoService.java
@Service
public class DemoService {
//I'll say hello
public String hello() {
return "hello";
}
//I'll divide
public BigDecimal divide(BigDecimal a, BigDecimal b) {
return a.divide(b, 2, RoundingMode.HALF_UP);
}
}
The other is to get an external resource (Qiita API).
ExternalService.java
@Service
@RequiredArgsConstructor
public class ExternalService {
private static final String EXTERNAL_RESOURCE_URL = "https://qiita.com/api/v2/schema";
private final RestTemplate restTemplate;
//Returns the result of Schema of Qiita API
public String getExternalResource() {
ResponseEntity<String> response =
restTemplate.exchange(EXTERNAL_RESOURCE_URL, HttpMethod.GET, null, String.class);
return response.getBody();
}
}
Create a Configuration
class to DI the above restTemplate
.
AppConfig.java
@Configuration
public class AppConfig {
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
Create a Controller that uses the two Services mentioned earlier.
DemoController.java
@RestController
@RequiredArgsConstructor
@Validated
public class DemoController {
private final DemoService demoService;
private final ExternalService externalService;
//I'll say hello
@GetMapping("/")
public CommonResponse hello() {
String data = demoService.hello();
return CommonResponse.builder().data(data).build();
}
//I'll divide
@GetMapping("/divide/{num1}/{num2}")
public CommonResponse divide(
@PathVariable @Pattern(regexp = "[0-9]*") String num1,
@PathVariable @Pattern(regexp = "[0-9]*") String num2) {
BigDecimal data = demoService.divide(new BigDecimal(num1), new BigDecimal(num2));
return CommonResponse.builder().data(data).build();
}
//Returns the result of Schema of Qiita API
@GetMapping("/external")
public CommonResponse external() {
String data = externalService.getExternalResource();
return CommonResponse.builder().data(data).build();
}
}
Since the division is checking the input, we will implement the test case later from this point of view. Create the response class as follows.
CommonResponse.java
@Data
@Builder
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CommonResponse<T> {
@Builder.Default
private String status = "success";
@Builder.Default
private String message = "request succeeded.";
private T data;
}
We will also create a Filter and an ExceptionHandler for a more practical sample. This filter is as simple as outputting a log before and after processing a request.
LogFilter.java
@Component
@Slf4j
public class LogFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
log.info("[IN]{}:{}", req.getMethod(), req.getRequestURI());
try {
chain.doFilter(request, response);
} finally {
log.info("[OUT]{}:{}", req.getMethod(), req.getRequestURI());
}
}
}
Also create an ExceptionHandler. It is used to handle various errors in common.
CommonExceptionHandler.java
@RestControllerAdvice
@Slf4j
public class CommonExceptionHandler extends ResponseEntityExceptionHandler {
// 404:Handles Resource Not Found errors
//* To handle this application.You also need to set properties
@Override
protected ResponseEntity handleNoHandlerFoundException(NoHandlerFoundException ex, HttpHeaders headers, HttpStatus status, WebRequest request) {
ServletWebRequest req = (ServletWebRequest)request;
log.warn("resource not found. {}", req.getRequest().getRequestURI());
return new ResponseEntity(
CommonResponse.builder().status("failure").message("resource not found.").build(),
HttpStatus.NOT_FOUND);
}
// 400:Handles input check errors
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<CommonResponse> handleValidationError(ConstraintViolationException e) {
//Separate input error items and messages with commas(,)Added to.
String validationErrorMessages =
e.getConstraintViolations().stream()
.map(cv -> cv.getPropertyPath().toString() + ":" + cv.getMessage())
.collect(Collectors.joining(", "));
log.info("Bad request. {}", validationErrorMessages);
return new ResponseEntity<>(
CommonResponse.builder().status("failure").message(validationErrorMessages).build(),
HttpStatus.BAD_REQUEST);
}
// 500:Handles other unknown errors
@ExceptionHandler
public ResponseEntity<CommonResponse> handleException(Exception e) {
log.error("Request failed.", e);
return new ResponseEntity<>(
CommonResponse.builder().status("failure").message("error has occurred.").build(),
HttpStatus.INTERNAL_SERVER_ERROR);
}
}
Put the following settings in ʻapplication.properties to handle
404: Resouce Not Found` in the above ExceptionHandler.
application.properties
spring.mvc.throw-exception-if-no-handler-found=true
spring.resources.add-mappings=false
This completes the preparation of the application to be used for testing. The files edited and created up to this point have the following structure.
├── build.gradle
├── src
│ ├── main
│ │ ├── java
│ │ │ └── com.example.demo
│ │ │ ├── AppConfig.java
│ │ │ ├── DemoApplication.java
│ │ │ ├── controller
│ │ │ │ ├── CommonExceptionHandler.java
│ │ │ │ ├── CommonResponse.java
│ │ │ │ └── DemoController.java
│ │ │ ├── filter
│ │ │ │ └── LogFilter.java
│ │ │ └── service
│ │ │ ├── DemoService.java
│ │ │ └── ExternalService.java
│ │ └── resources
│ │ └── application.properties
│ └── test
└── web
The main roles of Controller are as follows.
--Request mapping --Getting parameters
Since these behaviors depend on the functionality of Spring MVC, it doesn't make much sense to write test code for the Controller class alone.
Therefore, we will use MockMVC
to perform a test that reproduces the behavior of Spring MVC.
Regarding the request accepted by the Controller, not only the normal case but also the response of the assumed abnormal case such as an input check error is verified without exception.
DemoControllerTest.java
package com.example.demo.controller;
import com.example.demo.filter.LogFilter;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@EnableWebMvc
@Slf4j
public class DemoControllerTest {
MockMvc mockMvc;
@Autowired WebApplicationContext webApplicationContext;
@Autowired LogFilter logFilter;
@BeforeEach
void beforeEach() {
mockMvc =
MockMvcBuilders.webAppContextSetup(webApplicationContext) //Set up Mock MVC
.addFilter(logFilter, "/*") //However, filter needs to be added manually
.build();
}
//root"/I'll test your request
@Test
void hello() throws Exception {
mockMvc.perform(get("/")) //root"/Send a pseudo request to
.andExpect(status().isOk()) //HttpStatus is 200:Be OK
.andExpect(jsonPath("$.status").value("success")) //The value of json is as expected
.andExpect(jsonPath("$.message").value("request succeeded.")) // 〃
.andExpect(jsonPath("$.data").value("hello"));
}
//I'll test the division (10/3) request
@Test
void divideSuccess() throws Exception {
mockMvc
.perform(get("/divide/10/3")) // 「/divide/10/Send pseudo request to 3 "
.andExpect(status().isOk()) //HttpStatus is 200:Be OK
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").value("3.33")); // 10 ÷ 3 = 3.Be 33
}
//Test for bad requests (10 ÷ aaa)
@Test
void divideInvalidParameter() throws Exception {
mockMvc
.perform(get("/divide/10/aaa")) // 「/divide/10/Send pseudo request to aaa "
.andExpect(status().isBadRequest()) //HttpStatus is 400:Being a BadRequest
.andExpect(jsonPath("$.status").value("failure"))
.andExpect(jsonPath("$.message").value("divide.num2:must match \"[0-9]*\"")); //There is an error message
}
//I'll test a request for division by zero (10 ÷ 0)
@Test
void divideZeroError() throws Exception {
mockMvc
.perform(get("/divide/10/0")) // 「/divide/10/Send pseudo request to "0"
.andExpect(status().is5xxServerError()) //HttpStatus is 500:ServerError
.andExpect(jsonPath("$.status").value("failure"))
.andExpect(jsonPath("$.message").value("error has occurred."));
}
//Test the acquisition of external resources (Qiita schema API)
@Test
void getExternalResource() throws Exception {
MvcResult mvcResult =
mockMvc
.perform(get("/external"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").isNotEmpty()) //Not empty
.andReturn();
//I will output the acquired response to the log
log.info("external response : {}", mvcResult.getResponse().getContentAsString());
}
}
The point is to set up MockMvc
with WebAppicationContext
. [^ 1]
By doing this, it reproduces almost the same state as deploying to the application server.
[^ 1]: MockMvc also has another standalone
setup, and there is also a mode suitable for unit tests that can be finely customized such as Controller, ControllerAdvice, Config to be tested.
** However, note that only Filter
needs to be specified by ʻaddFilter (Filter," path ")`. ** **
The previous test used DI Controller and Service.
However, when using an external resource such as ʻExternalService`, it is not good for the controller test to fail due to the state (the other server is down and inaccessible, Wi-Fi is turned off, etc.). ..
** Use Mock at such times. ** **
If you check the role of Controller again,
However, of these, 4. Calling Business Logic (Service)
can be ** mocked to return the expected value so that you can focus on testing the Controller **.
Let's make the Service class of the previous test into a Mock.
DemoControllerWithMockTest.java
package com.example.demo.controller;
import com.example.demo.filter.LogFilter;
import com.example.demo.service.DemoService;
import com.example.demo.service.ExternalService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import java.math.BigDecimal;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@EnableWebMvc
@Slf4j
class DemoControllerWithMockTest {
MockMvc mockMvc;
@Autowired WebApplicationContext webApplicationContext;
@Autowired LogFilter logFilter;
@MockBean DemoService demoService; //Convert to Mock and register in DI container
@MockBean ExternalService externalService; // 〃
@BeforeEach
void beforeEach() {
MockitoAnnotations.initMocks(this);
mockMvc =
MockMvcBuilders.webAppContextSetup(webApplicationContext)
.addFilter(logFilter, "/*")
.build();
}
@AfterEach
void afterEach() {}
@Test
void hello() throws Exception {
// mock
when(demoService.hello()).thenReturn("Hello"); //First set the return value of the mock
// request execute
mockMvc
.perform(get("/"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").value("Hello")); //Verify by changing the expected value
// verify
verify(demoService, times(1)).hello(); //Verify the number of mock calls
}
@Test
void divideSuccess() throws Exception {
// mock
when(demoService.divide(any(), any())).thenReturn(new BigDecimal("3.33")); //Regardless of the argument"3.33"return it
// request execute
mockMvc
.perform(get("/divide/10/3"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").value("3.33"));
// verify
verify(demoService, times(1)).divide(any(), any());
}
@Test
void divideInvalidParameter() throws Exception {
// request execute
mockMvc
.perform(get("/divide/10/aaa"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value("failure"))
.andExpect(jsonPath("$.message").value("divide.num2:must match \"[0-9]*\""));
// verify
verify(demoService, times(0)).divide(any(), any()); //Mock call validated 0 times due to input error
}
@Test
void divideZeroError() throws Exception {
// mock
when(demoService.divide(any(), eq(BigDecimal.ZERO)))
.thenThrow(new ArithmeticException("/ by zero")); //Reproduce the error assuming division by zero
// request execute
mockMvc
.perform(get("/divide/10/0"))
.andExpect(status().is5xxServerError())
.andExpect(jsonPath("$.status").value("failure"))
.andExpect(jsonPath("$.message").value("error has occurred."));
// verify
verify(demoService, times(1)).divide(any(), eq(BigDecimal.ZERO)); //Mock call validates once
}
@Test
void getExternalResource() throws Exception {
// mock
when(externalService.getExternalResource())
.thenReturn("this is mock data for internal test."); //Return wording without accessing external resources
// request execute
MvcResult mvcResult =
mockMvc
.perform(get("/external"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").value("this is mock data for internal test."))
.andReturn();
//I will output the acquired response to the log
log.info("external response : {}", mvcResult.getResponse().getContentAsString());
// verify
verify(externalService, times(1)).getExternalResource(); //Mock call validates once
}
}
Most importantly with this change is that ʻExternalService` now returns mock data without accessing external resources.
You can now run Controller tests independently of external resources.
In the previous test, both DemoService
and ʻExternalService were Mocked, but ** only one can be Mocked **. For example, if you want to Mock only ʻExternalService
, it will be as follows.
DemoControllerWithOneSideMockTest.java
package com.example.demo.controller;
import com.example.demo.filter.LogFilter;
import com.example.demo.service.ExternalService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.MockitoAnnotations;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@SpringBootTest
@EnableWebMvc
@Slf4j
class DemoControllerWithOneSideMockTest {
MockMvc mockMvc;
@Autowired WebApplicationContext webApplicationContext;
@Autowired LogFilter logFilter;
@MockBean ExternalService externalService; //External Service has external access, so make it a Mock
@BeforeEach
void beforeEach() {
MockitoAnnotations.initMocks(this);
mockMvc =
MockMvcBuilders.webAppContextSetup(webApplicationContext)
.addFilter(logFilter, "/*")
.build();
}
@AfterEach
void afterEach() {}
@Test
void hello() throws Exception {
mockMvc
.perform(get("/"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").value("hello"));
}
@Test
void divideSuccess() throws Exception {
mockMvc
.perform(get("/divide/10/3"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").value("3.33"));
}
@Test
void divideInvalidParameter() throws Exception {
mockMvc
.perform(get("/divide/10/aaa"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value("failure"))
.andExpect(jsonPath("$.message").value("divide.num2:must match \"[0-9]*\""));
}
@Test
void divideZeroError() throws Exception {
mockMvc
.perform(get("/divide/10/0"))
.andExpect(status().is5xxServerError())
.andExpect(jsonPath("$.status").value("failure"))
.andExpect(jsonPath("$.message").value("error has occurred."));
}
//Only the acquisition of external resources is verified using Mock.
@Test
void getExternalResource() throws Exception {
// mock
when(externalService.getExternalResource()).thenReturn("this is mock data for internal test.");
// request
MvcResult mvcResult =
mockMvc
.perform(get("/external"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("success"))
.andExpect(jsonPath("$.message").value("request succeeded."))
.andExpect(jsonPath("$.data").value("this is mock data for internal test."))
.andReturn();
//
log.info("external response : {}", mvcResult.getResponse().getContentAsString());
// verify
verify(externalService, times(1)).getExternalResource();
}
}
With this, you can freely DI the bean at the time of testing or mock it. Very convenient! !!
Finally, I will leave a sample of the convenience function parameterization test
added in JUnit5 by taking the unit test of Service
as an example.
It will be easy to repeat the same case by changing only the test data.
DemoServiceTest.java
package com.example.demo.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest
class DemoServiceTest {
@Autowired DemoService demoService;
@Test
void hello() {
assertEquals("hello", demoService.hello());
}
// @Test the division method with various input patterns with ParameterizedTest
@ParameterizedTest
@MethodSource("divideTestArgs") // "devideTestArgs"I'll use a static method named as the argument source
void divide(String b1, String b2, String strExpect, boolean hasError) {
BigDecimal expect = Optional.ofNullable(strExpect).map(BigDecimal::new).orElse(null);
BigDecimal actual = null;
Exception error = null;
//Division method execution
try {
actual = demoService.divide(new BigDecimal(b1), new BigDecimal(b2));
} catch (Exception e) {
error = e;
}
//Expected value and verification
assertEquals(expect, actual);
//Verify that no error has occurred
assertEquals(hasError, error != null);
}
//divide test parameter list
static List<Object[]> divideTestArgs() {
return List.of(
new Object[] {"1", "1", "1.00", false},
new Object[] {"0", "1", "0.00", false},
new Object[] {"5", "2", "2.50", false},
new Object[] {"10", "3", "3.33", false}, //Rounding (rounding down to the third decimal place)
new Object[] {"11", "3", "3.67", false}, //Rounding (rounding up to the third decimal place)
new Object[] {"1", "0", null, true}); //Division by zero
}
}
Besides, MethodSource
It seems that you can use such as as a data source.
About the function of JUnit5 [this page](https://qiita.com/opengl-8080/items/efe54204e25f615e322f#%E3%83%91%E3%83%A9%E3%83%A1%E3%83% BC% E3% 82% BF% E5% 8C% 96% E3% 83% 86% E3% 82% B9% E3% 83% 88) was very easy to understand! I was very helpful mm
There are many articles about testing JUnit, but there are also quite a few articles from older versions, so I hope it will be helpful for beginners who are challenging from Spring Boot and JUnit 5.
Recommended Posts