Writing Unit Tests for a Spring MVC REST API: Writing Data

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 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:

<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:

  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:

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:

  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:

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:

  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:

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 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.
  3. Ensure that the system under test returns the HTTP status code 201.
  4. Verify that the system under test returns the information of the created todo item as JSON.
  5. Ensure that the system under test returns the information of the created todo item.
  6. Verify that the system under test creates a new todo item that has the correct description.
  7. 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 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.

0 comments… add one

Leave a Reply