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 POST requests 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.

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 a StatusResultMatchers object which allows us to write assertions for the returned HTTP status.
  • The content() method returns a ContentResultMatchers object which allows us to write assertions for the content of the returned HTTP response.
  • The jsonPath() method returns a JsonPathResultMatchers object which allows us to write assertions for the body of the returned HTTP response by using JsonPath expressions and Hamcrest matchers.

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:

  1. Add a private and static method called convertObjectToJsonBytes() our request builder class and ensure that this method returns a byte array.
  2. Ensure that the convertObjectToJsonBytes() method takes an Object object as a method parameter and converts this object into a byte array which contains a JSON document.
  3. Add a new method called create() to our request builder class. Ensure that this method takes a CreateTodoItemDTO object as a method parameter and returns a ResultActions object.
  4. Send a POST request to the path: ‘/todo-item’ by invoking the perform() method of the MockMvc class. Remember to convert the information of the created todo item into a JSON document and add this information to body of the HTTP request.
  5. Return the ResultActions object that’s returned by the perform() 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}

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:

  1. Add an inner class called Create to our test class. This inner class contains the test methods which ensure that the system under test is working as expected.
  2. Add an inner class called WhenInvalidInformationIsProvided to the Create class. This inner class contains the test methods which ensure that the system under test is working as expected when validation fails.
  3. Add an inner class called WhenFieldValuesAreEmptyStrings to the WhenInvalidInformationIsProvided class. This inner class contains the test methods which ensure that the system under test is working as expected when the title and description of the created todo item are empty strings.
  4. Add an inner class called WhenValidInformationIsProvided to the Create class. 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:

  1. Add the required constants to the WhenFieldValuesAreEmptyStrings class.
  2. Add a new setup method to the WhenFieldValuesAreEmptyStrings class and ensure that it’s run before a test method is run. When we implement this method, we must create a new CreateTodoItemDTO object that has an empty title and description, and store the created object in the input field.
  3. Ensure that the system under test returns the HTTP status code 400.
  4. Verify that the system under test returns validation errors as JSON.
  5. Ensure that the system under test returns one validation error.
  6. Verify that the system under test returns a validation error about an empty title.
  7. 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:

  1. Add the required constants to the WhenValidInformationIsProvided class.
  2. Add a new setup method to the WhenValidInformationIsProvided class and ensure that it’s run before a test method is run. When we implement this method, we must:
  • Create a new CreateTodoItemDTO object that has valid title and description. After we have created this object, we must store it in the input field.
  • Ensure that the create() method of the TodoItemCrudService class returns the information of the created todo item.
  1. Ensure that the system under test returns the HTTP status code 201.
  2. Verify that the system under test returns the information of the created todo item as JSON.
  3. Ensure that the system under test returns the information of the created todo item.
  4. Verify that the system under test creates a new todo item that has the correct description.
  5. 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}

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 the MockMvcResultMatchers class.
  • When we want to write assertions for the content of the returned HTTP response, we have to invoke the content() method of the MockMvcResultMatchers class.
  • 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 the MockMvcResultMatchers class.
  • 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-path and hamcrest-library dependencies are found from the classpath

P.S. You can get the example application of this blog post from Github.