The previous parts of my Spring MVC Test tutorial described how you we can write unit tests for a Spring MVC REST API when the system under test returns the information of a single item or returns a list. In other words, now we know how we can write unit tests for Spring MVC controllers which return data as JSON.
It’s time to take the next step. This blog post describes how we can write unit tests for a Spring MVC REST API endpoint which reads data from the request body, inserts valid data into a database, and returns data as JSON.
After we have finished this blog post, we:
- Know how we can send
POSTrequests to the system under test and configure the request body of the HTTP request. - Understand how we can ensure that the system under test is working as expected when validation fails.
- Know how we can ensure that the system under test is working as expected when validation is successful.
Let’s begin.
This blog post assumes that:
Introduction to the System Under Test
We have to write unit tests for a controller method that processes POST requests send to the path: ‘/todo-item’. The contract of this API endpoint is described in the following:
- The validation rules must be specified by using the Jakarta Bean Validation API.
- If validation fails, the system under test returns the HTTP status code 400.
- If validation fails, the system under test returns a JSON document that describes the validation errors found from the input data.
- If a new todo item was created successfully, the system under test returns the HTTP status code 201.
- If a new todo item was created successfully, the system under test returns a JSON document that contains the information of the created todo item.
The following examples illustrate the JSON documents which are returned back to the client:
Example 1: The client tried to create a new todo item that has no title
{
"fieldErrors":[
{
"field":"title",
"errorCode":"NotBlank"
}
]
}
Example 2: A new todo item was created successfully
{
"id":1,
"description":"This is just an example",
"tags":[],
"title":"Create a new todo item",
"status":"OPEN"
}
The tested controller method is called create(). It simply saves a new todo item to the database and returns the information of the created todo item. The source code of the tested controller method looks as follow:
1import org.springframework.beans.factory.annotation.Autowired;
2import org.springframework.http.HttpStatus;
3import org.springframework.web.bind.annotation.PostMapping;
4import org.springframework.web.bind.annotation.RequestBody;
5import org.springframework.web.bind.annotation.RequestMapping;
6import org.springframework.web.bind.annotation.ResponseStatus;
7import org.springframework.web.bind.annotation.RestController;
8
9import javax.validation.Valid;
10
11@RestController
12@RequestMapping("/todo-item")
13public class TodoItemCrudController {
14 private final TodoItemCrudService service;
15
16 @Autowired
17 public TodoItemCrudController(TodoItemCrudService service) {
18 this.service = service;
19 }
20
21 @PostMapping
22 @ResponseStatus(HttpStatus.CREATED)
23 public TodoItemDTO create(@RequestBody @Valid CreateTodoItemDTO input) {
24 return service.create(input);
25 }
26}
The CreateTodoItemDTO class contains the information of the created todo item. It also declares the validation rules that are used to validate this information. Its source code looks as follows:
1import javax.validation.constraints.NotBlank;
2import javax.validation.constraints.Size;
3
4public class CreateTodoItemDTO {
5
6 @Size(max = 1000)
7 private String description;
8
9 @NotBlank
10 @Size(max = 100)
11 private String title;
12
13 //Getters and setters are omitted
14}
The TodoItemDTO class contains the information of the created todo item. Its source code looks as follows:
1import java.util.List;
2
3public class TodoItemDTO {
4
5 private Long id;
6 private String description;
7 private List<TagDTO> tags;
8 private String title;
9 private TodoItemStatus status;
10
11 //Getters and setters are omitted
12}
The TagDTO class contains the information of a single tag. Its source code looks as follows:
1public class TagDTO {
2
3 private Long id;
4 private String name;
5
6 //Getters and setters are omitted
7}
The TodoItemStatus enum specifies the possible statuses of a todo item. Its source code looks as follows:
1public enum TodoItemStatus {
2 OPEN,
3 IN_PROGRESS,
4 DONE
5}
Next, we will learn how we can write assertions for the response returned by the system under test.
Writing Assertions for the Response Returned by the System Under Test
Before we can write unit tests for a Spring MVC controller which saves data to the database and returns data as JSON, we have to learn how we can write assertions for the HTTP response returned by the system under test. When we want to write assertions for the HTTP response returned by the tested Spring MVC controller, we have to use these static methods of the MockMvcResultMatchers class:
- The
status()method returns aStatusResultMatchersobject which allows us to write assertions for the returned HTTP status. - The
content()method returns aContentResultMatchersobject which allows us to write assertions for the content of the returned HTTP response. - The
jsonPath()method returns aJsonPathResultMatchersobject which allows us to write assertions for the body of the returned HTTP response by using JsonPath expressions and Hamcrest matchers.
Additional Reading:
- The Javadoc of the
ContentResultMatchersclass - [The Javadoc of the
JsonPathResultMatchersclass - The Javadoc of the
StatusResultMatchersclass](https://docs.spring.io/spring-framework/docs/5.3.x/javadoc-api/org/springframework/test/web/servlet/result/JsonPathResultMatchers.html) - The README of the Jayway JsonPath Project
Because we are writing assertions by using JsonPath expressions and Hamcrest matchers, we must ensure that the json-path and hamcrest-library dependencies are found from the classpath. If we are using Maven and Spring Boot dependency management, we can declare these dependencies by adding the following XML snippet to the dependencies section of our POM file:
1<dependency>
2 <groupId>org.hamcrest</groupId>
3 <artifactId>hamcrest-library</artifactId>
4 <scope>test</scope>
5</dependency>
6<dependency>
7 <groupId>com.jayway.jsonpath</groupId>
8 <artifactId>json-path</artifactId>
9 <scope>test</scope>
10</dependency>
Let’s move on and find out how we can write a request builder method which sends POST requests to the system under test.
Writing a New Request Builder Method
Because we want to remove duplicate code from our test class, we have to create and send HTTP requests to the system under test by using a so called request builder class. In other words, before we can write unit tests for the system under test, we have write to a request builder method which creates and sends HTTP requests to the system under test. We can write this request builder method by following these steps:
- Add a
privateandstaticmethod calledconvertObjectToJsonBytes()our request builder class and ensure that this method returns a byte array. - Ensure that the
convertObjectToJsonBytes()method takes anObjectobject as a method parameter and converts this object into a byte array which contains a JSON document. - Add a new method called
create()to our request builder class. Ensure that this method takes aCreateTodoItemDTOobject as a method parameter and returns aResultActionsobject. - Send a
POSTrequest to the path: ‘/todo-item’ by invoking theperform()method of theMockMvcclass. Remember to convert the information of the created todo item into a JSON document and add this information to body of the HTTP request. - Return the
ResultActionsobject that’s returned by theperform()method.
After we have written our request builder method, the source code of our request builder class looks as follows:
1import com.fasterxml.jackson.databind.ObjectMapper;
2import org.springframework.http.MediaType;
3import org.springframework.test.web.servlet.MockMvc;
4import org.springframework.test.web.servlet.ResultActions;
5
6import java.io.IOException;
7
8import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.objectMapper;
9import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
10
11class TodoItemRequestBuilder {
12
13 private final MockMvc mockMvc;
14
15 TodoItemRequestBuilder(MockMvc mockMvc) {
16 this.mockMvc = mockMvc;
17 }
18
19 ResultActions create(CreateTodoItemDTO input) throws Exception {
20 return mockMvc.perform(post("/todo-item")
21 .contentType(MediaType.APPLICATION_JSON)
22 .content(convertObjectToJsonBytes(input))
23 );
24 }
25
26 private static byte[] convertObjectToJsonBytes(Object object) throws IOException {
27 ObjectMapper mapper = objectMapper();
28 return mapper.writeValueAsBytes(object);
29 }
30}
Additional Reading:
Next, we will learn to write unit tests for the system under test.
Writing Unit Tests for the System Under Test
When we want to write unit tests for the system under test, we have to follow these steps:
First, we have to add the required class hierarchy to our test class. Because we are writing unit tests, we can create this class hierarchy by following these steps:
- Add an inner class called
Createto our test class. This inner class contains the test methods which ensure that the system under test is working as expected. - Add an inner class called
WhenInvalidInformationIsProvidedto theCreateclass. This inner class contains the test methods which ensure that the system under test is working as expected when validation fails. - Add an inner class called
WhenFieldValuesAreEmptyStringsto theWhenInvalidInformationIsProvidedclass. This inner class contains the test methods which ensure that the system under test is working as expected when thetitleanddescriptionof the created todo item are empty strings. - Add an inner class called
WhenValidInformationIsProvidedto theCreateclass. This inner class contains the test methods which ensure that the system under test is working as expected when validation is successful.
After we have added the required class hierarchy to our test class, its source code looks as follows:
1import org.junit.jupiter.api.BeforeEach;
2import org.junit.jupiter.api.DisplayName;
3import org.junit.jupiter.api.Nested;
4import org.springframework.test.web.servlet.MockMvc;
5import org.springframework.test.web.servlet.setup.MockMvcBuilders;
6
7import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.*;
8import static org.mockito.Mockito.mock;
9
10class TodoItemCrudControllerTest {
11
12 private TodoItemRequestBuilder requestBuilder;
13 private TodoItemCrudService service;
14
15 @BeforeEach
16 void configureSystemUnderTest() {
17 service = mock(TodoItemCrudService.class);
18
19 TodoItemCrudController testedController = new TodoItemCrudController(service);
20 MockMvc mockMvc = MockMvcBuilders.standaloneSetup(testedController)
21 .setControllerAdvice(new TodoItemErrorHandler())
22 .setMessageConverters(objectMapperHttpMessageConverter())
23 .build();
24 requestBuilder = new TodoItemRequestBuilder(mockMvc);
25 }
26
27 @Nested
28 @DisplayName("Create a new todo item")
29 class Create {
30
31 @Nested
32 @DisplayName("When the information of the created todo item isn't valid")
33 class WhenInvalidInformationIsProvided {
34
35 @Nested
36 @DisplayName("When the field values are empty strings")
37 class WhenFieldValuesAreEmptyStrings {
38
39 }
40 }
41
42 @Nested
43 @DisplayName("When the information of the created todo item is valid")
44 class WhenValidInformationIsProvided {
45
46 }
47 }
48}
Second, we have to add a private input field to the Create class. This field contains a reference to the CreateTodoItemDTO object which contains the information of the created todo item.
After we have added this field to the Create class, the source code of our test class looks as follows:
1import org.junit.jupiter.api.BeforeEach;
2import org.junit.jupiter.api.DisplayName;
3import org.junit.jupiter.api.Nested;
4import org.springframework.test.web.servlet.MockMvc;
5import org.springframework.test.web.servlet.setup.MockMvcBuilders;
6
7import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.*;
8import static org.mockito.Mockito.mock;
9
10class TodoItemCrudControllerTest {
11
12 private TodoItemRequestBuilder requestBuilder;
13 private TodoItemCrudService service;
14
15 @BeforeEach
16 void configureSystemUnderTest() {
17 service = mock(TodoItemCrudService.class);
18
19 TodoItemCrudController testedController = new TodoItemCrudController(service);
20 MockMvc mockMvc = MockMvcBuilders.standaloneSetup(testedController)
21 .setControllerAdvice(new TodoItemErrorHandler())
22 .setMessageConverters(objectMapperHttpMessageConverter())
23 .build();
24 requestBuilder = new TodoItemRequestBuilder(mockMvc);
25 }
26
27 @Nested
28 @DisplayName("Create a new todo item")
29 class Create {
30
31 private CreateTodoItemDTO input;
32
33 @Nested
34 @DisplayName("When the information of the created todo item isn't valid")
35 class WhenInvalidInformationIsProvided {
36
37 @Nested
38 @DisplayName("When the field values are empty strings")
39 class WhenFieldValuesAreEmptyStrings {
40
41 }
42 }
43
44 @Nested
45 @DisplayName("When the information of the created todo item is valid")
46 class WhenValidInformationIsProvided {
47
48 }
49 }
50}
Third, we have to ensure that the system under test is working as expected when we try to create a new todo item that has an empty title and description. We can write the required test methods by following these steps:
- Add the required constants to the
WhenFieldValuesAreEmptyStringsclass. - Add a new setup method to the
WhenFieldValuesAreEmptyStringsclass and ensure that it’s run before a test method is run. When we implement this method, we must create a newCreateTodoItemDTOobject that has an emptytitleanddescription, and store the created object in theinputfield. - Ensure that the system under test returns the HTTP status code 400.
- Verify that the system under test returns validation errors as JSON.
- Ensure that the system under test returns one validation error.
- Verify that the system under test returns a validation error about an empty title.
- Ensure that the system under test doesn’t create a new todo item.
After we have written the required test methods, the source code of our test class looks as follows:
1import org.junit.jupiter.api.BeforeEach;
2import org.junit.jupiter.api.DisplayName;
3import org.junit.jupiter.api.Nested;
4import org.junit.jupiter.api.Test;
5import org.springframework.http.MediaType;
6import org.springframework.test.web.servlet.MockMvc;
7import org.springframework.test.web.servlet.setup.MockMvcBuilders;
8
9import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.*;
10import static org.hamcrest.Matchers.contains;
11import static org.hamcrest.Matchers.hasSize;
12import static org.mockito.ArgumentMatchers.any;
13import static org.mockito.Mockito.mock;
14import static org.mockito.Mockito.never;
15import static org.mockito.Mockito.verify;
16import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
17import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
18import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
19
20class TodoItemCrudControllerTest {
21
22 private TodoItemRequestBuilder requestBuilder;
23 private TodoItemCrudService service;
24
25 @BeforeEach
26 void configureSystemUnderTest() {
27 service = mock(TodoItemCrudService.class);
28
29 TodoItemCrudController testedController = new TodoItemCrudController(service);
30 MockMvc mockMvc = MockMvcBuilders.standaloneSetup(testedController)
31 .setControllerAdvice(new TodoItemErrorHandler())
32 .setMessageConverters(objectMapperHttpMessageConverter())
33 .build();
34 requestBuilder = new TodoItemRequestBuilder(mockMvc);
35 }
36
37 @Nested
38 @DisplayName("Create a new todo item")
39 class Create {
40
41 private CreateTodoItemDTO input;
42
43 @Nested
44 @DisplayName("When the information of the created todo item isn't valid")
45 class WhenInvalidInformationIsProvided {
46
47 @Nested
48 @DisplayName("When the field values are empty strings")
49 class WhenFieldValuesAreEmptyStrings {
50
51 private static final String VALIDATION_ERROR_EMPTY_VALUE = "NotBlank";
52
53 @BeforeEach
54 void createInputWithEmptyFieldValues() {
55 input = new CreateTodoItemDTO();
56 input.setDescription("");
57 input.setTitle("");
58 }
59
60 @Test
61 @DisplayName("Should return the HTTP status code bad request (400)")
62 void shouldReturnHttpStatusCodeBadRequest() throws Exception {
63 requestBuilder.create(input)
64 .andExpect(status().isBadRequest());
65 }
66
67 @Test
68 @DisplayName("Should return validation errors as JSON")
69 void shouldReturnValidationErrorsAsJson() throws Exception {
70 requestBuilder.create(input)
71 .andExpect(
72 content().contentType(MediaType.APPLICATION_JSON)
73 );
74 }
75
76 @Test
77 @DisplayName("Should return one validation error")
78 void shouldReturnOneValidationError() throws Exception {
79 requestBuilder.create(input)
80 .andExpect(jsonPath("$.fieldErrors", hasSize(1)));
81 }
82
83 @Test
84 @DisplayName("Should return a validation error about empty title")
85 void shouldReturnValidationErrorAboutEmptyTitle() throws Exception {
86 requestBuilder.create(input)
87 .andExpect(jsonPath(
88 "$.fieldErrors[?(@.field == 'title')].errorCode",
89 contains(VALIDATION_ERROR_EMPTY_VALUE)
90 ));
91 }
92
93 @Test
94 @DisplayName("Shouldn't create a new todo item")
95 void shouldNotCreateNewTodoItem() throws Exception {
96 requestBuilder.create(input);
97
98 verify(service, never()).create(any());
99 }
100 }
101 }
102
103 //The other inner class is omitted
104 }
105}
The validation fails if:
- The title of the created todo item is
null - The title of the created todo item is empty.
- The title of the created todo item contains only whitespace characters.
- The title or description of the created todo item is too long.
This blog post covers only one scenario because I didn’t want to repeat myself. If you want to take a look at the tests which ensure that the system under test is working as expected in all possible scenarios, you should take a look at the example application of this blog post.
Fourth, we have to ensure that the system under test is working as expected when validation is successful. We can write the required test methods by following these steps:
- Add the required constants to the
WhenValidInformationIsProvidedclass. - Add a new setup method to the
WhenValidInformationIsProvidedclass and ensure that it’s run before a test method is run. When we implement this method, we must:
- Create a new
CreateTodoItemDTOobject that has validtitleanddescription. After we have created this object, we must store it in theinputfield. - Ensure that the
create()method of theTodoItemCrudServiceclass returns the information of the created todo item.
- Ensure that the system under test returns the HTTP status code 201.
- Verify that the system under test returns the information of the created todo item as JSON.
- Ensure that the system under test returns the information of the created todo item.
- Verify that the system under test creates a new todo item that has the correct description.
- Ensure that the system under test creates a new todo item that has the correct title.
After we have written the required test methods, the source code of our test class looks as follows:
1import org.junit.jupiter.api.BeforeEach;
2import org.junit.jupiter.api.DisplayName;
3import org.junit.jupiter.api.Nested;
4import org.junit.jupiter.api.Test;
5import org.springframework.http.MediaType;
6import org.springframework.test.web.servlet.MockMvc;
7import org.springframework.test.web.servlet.setup.MockMvcBuilders;
8
9import java.util.ArrayList;
10
11import static info.solidsoft.mockito.java8.AssertionMatcher.assertArg;
12import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.*;
13import static org.assertj.core.api.Assertions.assertThat;
14import static org.hamcrest.Matchers.equalTo;
15import static org.hamcrest.Matchers.hasSize;
16import static org.mockito.ArgumentMatchers.any;
17import static org.mockito.BDDMockito.given;
18import static org.mockito.Mockito.mock;
19import static org.mockito.Mockito.times;
20import static org.mockito.Mockito.verify;
21import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
22import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
23import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
24
25class TodoItemCrudControllerTest {
26
27 private TodoItemRequestBuilder requestBuilder;
28 private TodoItemCrudService service;
29
30 @BeforeEach
31 void configureSystemUnderTest() {
32 service = mock(TodoItemCrudService.class);
33
34 TodoItemCrudController testedController = new TodoItemCrudController(service);
35 MockMvc mockMvc = MockMvcBuilders.standaloneSetup(testedController)
36 .setControllerAdvice(new TodoItemErrorHandler())
37 .setMessageConverters(objectMapperHttpMessageConverter())
38 .build();
39 requestBuilder = new TodoItemRequestBuilder(mockMvc);
40 }
41
42 @Nested
43 @DisplayName("Create a new todo item")
44 class Create {
45
46 private CreateTodoItemDTO input;
47
48 //The other inner class is omitted
49
50 @Nested
51 @DisplayName("When the information of the created todo item is valid")
52 class WhenValidInformationIsProvided {
53
54 private static final int MAX_LENGTH_DESCRIPTION = 1000;
55 private static final int MAX_LENGTH_TITLE = 100;
56
57 private static final String DESCRIPTION = WebTestUtil
58 .createStringWithLength(MAX_LENGTH_DESCRIPTION);
59 private static final Long ID = 1L;
60 private static final String TITLE = WebTestUtil
61 .createStringWithLength(MAX_LENGTH_TITLE);
62
63 @BeforeEach
64 void configureSystemUnderTest() {
65 input = createInputWithValidInformation();
66 returnCreatedTodoItem();
67 }
68
69 private CreateTodoItemDTO createInputWithValidInformation() {
70 CreateTodoItemDTO input = new CreateTodoItemDTO();
71 input.setDescription(DESCRIPTION);
72 input.setTitle(TITLE);
73 return input;
74 }
75
76 private void returnCreatedTodoItem() {
77 TodoItemDTO created = new TodoItemDTO();
78 created.setId(ID);
79 created.setDescription(DESCRIPTION);
80 created.setStatus(TodoItemStatus.OPEN);
81 created.setTags(new ArrayList<>());
82 created.setTitle(TITLE);
83
84 given(service.create(any())).willReturn(created);
85 }
86
87 @Test
88 @DisplayName("Should return the HTTP status status code created (201)")
89 void shouldReturnHttpStatusCodeCreated() throws Exception {
90 requestBuilder.create(input)
91 .andExpect(status().isCreated());
92 }
93
94 @Test
95 @DisplayName("Should return the information of the created todo item as JSON")
96 void shouldReturnInformationOfCreatedTodoItemAsJSON() throws Exception {
97 requestBuilder.create(input)
98 .andExpect(content().contentType(MediaType.APPLICATION_JSON));
99 }
100
101 @Test
102 @DisplayName("Should return the information of the created todo item")
103 void shouldReturnInformationOfCreatedTodoItem() throws Exception {
104 requestBuilder.create(input)
105 .andExpect(jsonPath("$.id", equalTo(ID.intValue())))
106 .andExpect(jsonPath("$.description", equalTo(DESCRIPTION)))
107 .andExpect(jsonPath("$.status",
108 equalTo(TodoItemStatus.OPEN.name())
109 ))
110 .andExpect(jsonPath("$.tags", hasSize(0)))
111 .andExpect(jsonPath("$.title", equalTo(TITLE)));
112 }
113
114 @Test
115 @DisplayName("Should create a new todo item with the correct description")
116 void shouldCreateNewTodoItemWithCorrectDescription() throws Exception {
117 requestBuilder.create(input);
118 verify(service, times(1)).create(assertArg(
119 created -> assertThat(created.getDescription())
120 .isEqualTo(DESCRIPTION)
121 ));
122 }
123
124 @Test
125 @DisplayName("Should create a new todo item with the correct title")
126 void shouldCreateNewTodoItemWithCorrectTitle() throws Exception {
127 requestBuilder.create(input);
128 verify(service, times(1)).create(assertArg(
129 created -> assertThat(created.getTitle())
130 .isEqualTo(TITLE)
131 ));
132 }
133 }
134 }
135}
Additional Reading:
- The Javadoc of the
ContentResultMatchersclass - [The Javadoc of the
JsonPathResultMatchersclass - The Javadoc of the
StatusResultMatchersclass](https://docs.spring.io/spring-framework/docs/5.3.x/javadoc-api/org/springframework/test/web/servlet/result/JsonPathResultMatchers.html) - The README of the Jayway JsonPath Project
- The Javadoc of the
Matchersclass - The Javadoc of the
Mockitoclass - The README of the mockito-java8 library
We can now write unit tests for a Spring MVC REST API endpoint which inserts data into the database and returns data as JSON. Let’s summarize what we learned from this blog post.
Summary
This blog post has taught us four things:
- When we want to write assertions for the returned HTTP status, we have to invoke the
status()method of theMockMvcResultMatchersclass. - When we want to write assertions for the content of the returned HTTP response, we have to invoke the
content()method of theMockMvcResultMatchersclass. - When we want to write assertions for the body of the returned HTTP response by using JsonPath expressions and Hamcrest matchers, we have to invoke the
jsonPath()method of theMockMvcResultMatchersclass. - If we want to write assertions for the body of the returned HTTP response by using JsonPath expressions and Hamcrest matchers, we must ensure that the
json-pathandhamcrest-librarydependencies are found from the classpath
P.S. You can get the example application of this blog post from Github.