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:
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import javax.validation.Valid; @RestController @RequestMapping("/todo-item") public class TodoItemCrudController { private final TodoItemCrudService service; @Autowired public TodoItemCrudController(TodoItemCrudService service) { this.service = service; } @PostMapping @ResponseStatus(HttpStatus.CREATED) public TodoItemDTO create(@RequestBody @Valid CreateTodoItemDTO input) { return service.create(input); } }
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:
import javax.validation.constraints.NotBlank; import javax.validation.constraints.Size; public class CreateTodoItemDTO { @Size(max = 1000) private String description; @NotBlank @Size(max = 100) private String title; //Getters and setters are omitted }
The TodoItemDTO
class contains the information of the created todo item. Its source code looks as follows:
import java.util.List; public class TodoItemDTO { private Long id; private String description; private List<TagDTO> tags; private String title; private TodoItemStatus status; //Getters and setters are omitted }
The TagDTO
class contains the information of a single tag. Its source code looks as follows:
public class TagDTO { private Long id; private String name; //Getters and setters are omitted }
The TodoItemStatus
enum specifies the possible statuses of a todo item. Its source code looks as follows:
public enum TodoItemStatus { OPEN, IN_PROGRESS, DONE }
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 aStatusResultMatchers
object which allows us to write assertions for the returned HTTP status. - The
content()
method returns aContentResultMatchers
object which allows us to write assertions for the content of the returned HTTP response. - The
jsonPath()
method returns aJsonPathResultMatchers
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:
<dependency> <groupId>org.hamcrest</groupId> <artifactId>hamcrest-library</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>com.jayway.jsonpath</groupId> <artifactId>json-path</artifactId> <scope>test</scope> </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
private
andstatic
method calledconvertObjectToJsonBytes()
our request builder class and ensure that this method returns a byte array. - Ensure that the
convertObjectToJsonBytes()
method takes anObject
object 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 aCreateTodoItemDTO
object as a method parameter and returns aResultActions
object. - Send a
POST
request to the path: '/todo-item' by invoking theperform()
method of theMockMvc
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. - Return the
ResultActions
object 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:
import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import java.io.IOException; import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.objectMapper; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; class TodoItemRequestBuilder { private final MockMvc mockMvc; TodoItemRequestBuilder(MockMvc mockMvc) { this.mockMvc = mockMvc; } ResultActions create(CreateTodoItemDTO input) throws Exception { return mockMvc.perform(post("/todo-item") .contentType(MediaType.APPLICATION_JSON) .content(convertObjectToJsonBytes(input)) ); } private static byte[] convertObjectToJsonBytes(Object object) throws IOException { ObjectMapper mapper = objectMapper(); return mapper.writeValueAsBytes(object); } }
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
Create
to 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
WhenInvalidInformationIsProvided
to theCreate
class. 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
WhenFieldValuesAreEmptyStrings
to theWhenInvalidInformationIsProvided
class. This inner class contains the test methods which ensure that the system under test is working as expected when thetitle
anddescription
of the created todo item are empty strings. - Add an inner class called
WhenValidInformationIsProvided
to theCreate
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:
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.*; import static org.mockito.Mockito.mock; class TodoItemCrudControllerTest { private TodoItemRequestBuilder requestBuilder; private TodoItemCrudService service; @BeforeEach void configureSystemUnderTest() { service = mock(TodoItemCrudService.class); TodoItemCrudController testedController = new TodoItemCrudController(service); MockMvc mockMvc = MockMvcBuilders.standaloneSetup(testedController) .setControllerAdvice(new TodoItemErrorHandler()) .setMessageConverters(objectMapperHttpMessageConverter()) .build(); requestBuilder = new TodoItemRequestBuilder(mockMvc); } @Nested @DisplayName("Create a new todo item") class Create { @Nested @DisplayName("When the information of the created todo item isn't valid") class WhenInvalidInformationIsProvided { @Nested @DisplayName("When the field values are empty strings") class WhenFieldValuesAreEmptyStrings { } } @Nested @DisplayName("When the information of the created todo item is valid") class WhenValidInformationIsProvided { } } }
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:
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.*; import static org.mockito.Mockito.mock; class TodoItemCrudControllerTest { private TodoItemRequestBuilder requestBuilder; private TodoItemCrudService service; @BeforeEach void configureSystemUnderTest() { service = mock(TodoItemCrudService.class); TodoItemCrudController testedController = new TodoItemCrudController(service); MockMvc mockMvc = MockMvcBuilders.standaloneSetup(testedController) .setControllerAdvice(new TodoItemErrorHandler()) .setMessageConverters(objectMapperHttpMessageConverter()) .build(); requestBuilder = new TodoItemRequestBuilder(mockMvc); } @Nested @DisplayName("Create a new todo item") class Create { private CreateTodoItemDTO input; @Nested @DisplayName("When the information of the created todo item isn't valid") class WhenInvalidInformationIsProvided { @Nested @DisplayName("When the field values are empty strings") class WhenFieldValuesAreEmptyStrings { } } @Nested @DisplayName("When the information of the created todo item is valid") class WhenValidInformationIsProvided { } } }
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
WhenFieldValuesAreEmptyStrings
class. - 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 newCreateTodoItemDTO
object that has an emptytitle
anddescription
, and store the created object in theinput
field. - 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:
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.*; import static org.hamcrest.Matchers.contains; import static org.hamcrest.Matchers.hasSize; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class TodoItemCrudControllerTest { private TodoItemRequestBuilder requestBuilder; private TodoItemCrudService service; @BeforeEach void configureSystemUnderTest() { service = mock(TodoItemCrudService.class); TodoItemCrudController testedController = new TodoItemCrudController(service); MockMvc mockMvc = MockMvcBuilders.standaloneSetup(testedController) .setControllerAdvice(new TodoItemErrorHandler()) .setMessageConverters(objectMapperHttpMessageConverter()) .build(); requestBuilder = new TodoItemRequestBuilder(mockMvc); } @Nested @DisplayName("Create a new todo item") class Create { private CreateTodoItemDTO input; @Nested @DisplayName("When the information of the created todo item isn't valid") class WhenInvalidInformationIsProvided { @Nested @DisplayName("When the field values are empty strings") class WhenFieldValuesAreEmptyStrings { private static final String VALIDATION_ERROR_EMPTY_VALUE = "NotBlank"; @BeforeEach void createInputWithEmptyFieldValues() { input = new CreateTodoItemDTO(); input.setDescription(""); input.setTitle(""); } @Test @DisplayName("Should return the HTTP status code bad request (400)") void shouldReturnHttpStatusCodeBadRequest() throws Exception { requestBuilder.create(input) .andExpect(status().isBadRequest()); } @Test @DisplayName("Should return validation errors as JSON") void shouldReturnValidationErrorsAsJson() throws Exception { requestBuilder.create(input) .andExpect( content().contentType(MediaType.APPLICATION_JSON) ); } @Test @DisplayName("Should return one validation error") void shouldReturnOneValidationError() throws Exception { requestBuilder.create(input) .andExpect(jsonPath("$.fieldErrors", hasSize(1))); } @Test @DisplayName("Should return a validation error about empty title") void shouldReturnValidationErrorAboutEmptyTitle() throws Exception { requestBuilder.create(input) .andExpect(jsonPath( "$.fieldErrors[?(@.field == 'title')].errorCode", contains(VALIDATION_ERROR_EMPTY_VALUE) )); } @Test @DisplayName("Shouldn't create a new todo item") void shouldNotCreateNewTodoItem() throws Exception { requestBuilder.create(input); verify(service, never()).create(any()); } } } //The other inner class is omitted } }
- 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
WhenValidInformationIsProvided
class. - 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 validtitle
anddescription
. After we have created this object, we must store it in theinput
field. - Ensure that the
create()
method of theTodoItemCrudService
class returns the information of the created todo item.
- Create a new
- 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:
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import java.util.ArrayList; import static info.solidsoft.mockito.java8.AssertionMatcher.assertArg; import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.*; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.hasSize; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; class TodoItemCrudControllerTest { private TodoItemRequestBuilder requestBuilder; private TodoItemCrudService service; @BeforeEach void configureSystemUnderTest() { service = mock(TodoItemCrudService.class); TodoItemCrudController testedController = new TodoItemCrudController(service); MockMvc mockMvc = MockMvcBuilders.standaloneSetup(testedController) .setControllerAdvice(new TodoItemErrorHandler()) .setMessageConverters(objectMapperHttpMessageConverter()) .build(); requestBuilder = new TodoItemRequestBuilder(mockMvc); } @Nested @DisplayName("Create a new todo item") class Create { private CreateTodoItemDTO input; //The other inner class is omitted @Nested @DisplayName("When the information of the created todo item is valid") class WhenValidInformationIsProvided { private static final int MAX_LENGTH_DESCRIPTION = 1000; private static final int MAX_LENGTH_TITLE = 100; private static final String DESCRIPTION = WebTestUtil .createStringWithLength(MAX_LENGTH_DESCRIPTION); private static final Long ID = 1L; private static final String TITLE = WebTestUtil .createStringWithLength(MAX_LENGTH_TITLE); @BeforeEach void configureSystemUnderTest() { input = createInputWithValidInformation(); returnCreatedTodoItem(); } private CreateTodoItemDTO createInputWithValidInformation() { CreateTodoItemDTO input = new CreateTodoItemDTO(); input.setDescription(DESCRIPTION); input.setTitle(TITLE); return input; } private void returnCreatedTodoItem() { TodoItemDTO created = new TodoItemDTO(); created.setId(ID); created.setDescription(DESCRIPTION); created.setStatus(TodoItemStatus.OPEN); created.setTags(new ArrayList<>()); created.setTitle(TITLE); given(service.create(any())).willReturn(created); } @Test @DisplayName("Should return the HTTP status status code created (201)") void shouldReturnHttpStatusCodeCreated() throws Exception { requestBuilder.create(input) .andExpect(status().isCreated()); } @Test @DisplayName("Should return the information of the created todo item as JSON") void shouldReturnInformationOfCreatedTodoItemAsJSON() throws Exception { requestBuilder.create(input) .andExpect(content().contentType(MediaType.APPLICATION_JSON)); } @Test @DisplayName("Should return the information of the created todo item") void shouldReturnInformationOfCreatedTodoItem() throws Exception { requestBuilder.create(input) .andExpect(jsonPath("$.id", equalTo(ID.intValue()))) .andExpect(jsonPath("$.description", equalTo(DESCRIPTION))) .andExpect(jsonPath("$.status", equalTo(TodoItemStatus.OPEN.name()) )) .andExpect(jsonPath("$.tags", hasSize(0))) .andExpect(jsonPath("$.title", equalTo(TITLE))); } @Test @DisplayName("Should create a new todo item with the correct description") void shouldCreateNewTodoItemWithCorrectDescription() throws Exception { requestBuilder.create(input); verify(service, times(1)).create(assertArg( created -> assertThat(created.getDescription()) .isEqualTo(DESCRIPTION) )); } @Test @DisplayName("Should create a new todo item with the correct title") void shouldCreateNewTodoItemWithCorrectTitle() throws Exception { requestBuilder.create(input); verify(service, times(1)).create(assertArg( created -> assertThat(created.getTitle()) .isEqualTo(TITLE) )); } } } }
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 theMockMvcResultMatchers
class. - When we want to write assertions for the content of the returned HTTP response, we have to invoke the
content()
method of theMockMvcResultMatchers
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 theMockMvcResultMatchers
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
andhamcrest-library
dependencies are found from the classpath
P.S. You can get the example application of this blog post from Github.