Writing Unit Tests for Spring MVC Controllers: Forms

The previous part of my Spring MVC Test tutorial described how we can write unit tests for a Spring MVC controller that renders a list. This blog post provides more information about writing unit tests for Spring MVC controllers which insert data into the used database. To be more specific, this blog post describes how we can write unit tests for a Spring MVC controller that submits a form.

After we have finished this blog post, we:

  • Know how we can submit a form by using the Spring MVC Test framework.
  • Understand how we can ensure that the system under test displays the correct validation errors when we submit a form that contains invalid information.
  • Know how we can can ensure that the fields of the submitted form contain the correct information if validation fails.
  • Can verify that the HTTP request is redirected to the correct path.
  • Know how we can ensure that the system under test displays the correct flash message to the user.

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-items'. This method creates a new todo item and redirects the user to the view todo item view. If validation fails, this controller method returns the HTTP status code 200 and renders the form view.

The tested controller method is called create() and it's implemented by following these steps:

  1. If the submitted form has validation errors, return the name of the form view ('todo-item/create').
  2. Save the created todo item to the database by invoking the create() method of the TodoItemCrudService class.
  3. Create a feedback message which states that a new todo item was created and ensure that this message is shown when the next view is rendered.
  4. Redirect the HTTP request to the view that renders the information of the created todo item.

The source code of the tested controller method looks as follows:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import javax.validation.Valid;
import java.util.Locale;

@Controller
@RequestMapping("/todo-item")
public class TodoItemCrudController {

    private final MessageSource messageSource;
    private final TodoItemCrudService service;

    @Autowired
    public TodoItemCrudController(MessageSource messageSource, 
                                  TodoItemCrudService service) {
        this.messageSource = messageSource;
        this.service = service;
    }

    @PostMapping
    public String create(@Valid @ModelAttribute("todoItem") CreateTodoItemFormDTO form,
                         BindingResult bindingResult,
                         RedirectAttributes redirectAttributes,
                         Locale currentLocale) {
        if (bindingResult.hasErrors()) {
            return "todo-item/create";
        }

        TodoItemDTO created = service.create(form);

        addFeedbackMessage(
                redirectAttributes,
                "feedback.message.todoItem.created",
                currentLocale,
                created.getTitle()
        );

        redirectAttributes.addAttribute("id", created.getId());
        return "redirect:/todo-item/{id}";
    }

    private void addFeedbackMessage(RedirectAttributes attributes,
                                    String messageCode,
                                    Locale currentLocale,
                                    Object... messageParameters) {
        String feedbackMessage = messageSource.getMessage(messageCode,
                messageParameters,
                currentLocale
        );
        attributes.addFlashAttribute("feedbackMessage", feedbackMessage);
    }
}

The CreateTodoItemFormDTO class contains the information of the form object that's used to create new todo items. It also declares the validation rules which are used to validate the form object. The source code of the CreateTodoItemFormDTO class looks as follows:

import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;

public class CreateTodoItemFormDTO {

    @Size(max = 1000)
    private String description;

    @NotBlank
    @Size(max = 100)
    private String title;

    //Getters and setters are omitted
}

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 submits a form, we have to learn how we can write assertions for the response returned by the system under test. When we want to write assertions for the 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 view() method returns a ViewResultMatchers object which allows us to write assertions for the rendered view.
  • The model() method returns a ModelResultMatchers object which allows us to write assertions for the Spring MVC model.
  • The flash() method returns a FlashAttributeResultMatchers object which allows us to write assertions for the flash attributes (aka flash messages) shown to the user.

We are now ready to write unit tests for the system under test. Let’s start by writing a new request builder method.

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 sends HTTP requests to the system under test. We can write this request builder method by following these steps:

First, we have to add a new method called create() to our request builder class. This method takes a CreateTodoItemFormDTO object as a method parameter and returns a ResultActions object.

After we have added this method to our request builder class, its source code looks as follows:

import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;

class TodoItemRequestBuilder {

    private final MockMvc mockMvc;

    TodoItemRequestBuilder(MockMvc mockMvc) {
        this.mockMvc = mockMvc;
    }

    ResultActions create(CreateTodoItemFormDTO formObject) throws Exception {
        
    }
}

Second, we have to implement the create() method by following these steps:

  1. Send a POST request to the path: '/todo-item' by invoking the perform() method of the MockMvc class. Remember to return the ResultActions object that's returned by the perform() method.
  2. Configure the field values of the submitted form by using the param() method of the MockHttpServletRequestBuilder class.

After we have implemented the create() method, the source code of our request builder class looks as follows:

import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

class TodoItemRequestBuilder {

    private final MockMvc mockMvc;

    TodoItemRequestBuilder(MockMvc mockMvc) {
        this.mockMvc = mockMvc;
    }
    
    ResultActions create(CreateTodoItemFormDTO formObject) throws Exception {
        return mockMvc.perform(post("/todo-item")
                .param("description", formObject.getDescription())
                .param("title", formObject.getTitle())
        );
    }
}

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 SubmitFormThatCreatesNewTodoItems 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 WhenValidationFails to the SubmitFormThatCreatesNewTodoItems 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 WhenValidationIsSuccessful to the SubmitFormThatCreatesNewTodoItems 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.context.support.StaticMessageSource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.exceptionResolver;
import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.fixedLocaleResolver;
import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.jspViewResolver;
import static org.mockito.Mockito.mock;

public class TodoItemCrudControllerTest {

    private StaticMessageSource messageSource = new StaticMessageSource();
    private TodoItemRequestBuilder requestBuilder;
    private TodoItemCrudService service;

    @BeforeEach
    void configureSystemUnderTest() {
        service = mock(TodoItemCrudService.class);

        MockMvc mockMvc = MockMvcBuilders
                .standaloneSetup(new TodoItemCrudController(messageSource, service))
                .setHandlerExceptionResolvers(exceptionResolver())
                .setLocaleResolver(fixedLocaleResolver())
                .setViewResolvers(jspViewResolver())
                .build();
        requestBuilder = new TodoItemRequestBuilder(mockMvc);
    }

    @Nested
    @DisplayName("Submit the create todo item form")
    class SubmitCreateTodoItemForm {

        @Nested
        @DisplayName("When validation fails")
        class WhenValidationFails {

        }

        @Nested
        @DisplayName("When validation is successful")
        class WhenValidationIsSuccessful {

        }
    }
}

Second, we have to make the following changes to the SubmitFormThatCreatesNewTodoItems class:

  1. Declare the constants which are used by the test methods found from the WhenValidationFails and WhenValidationIsSuccessful inner classes.
  2. Add a private field to the SubmitFormThatCreatesNewTodoItems class. This field is called formObject and it contains the information of the created todo item.

After we have made these changes to the 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.context.support.StaticMessageSource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.exceptionResolver;
import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.fixedLocaleResolver;
import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.jspViewResolver;
import static org.mockito.Mockito.mock;

public class TodoItemCrudControllerTest {

    private StaticMessageSource messageSource = new StaticMessageSource();
    private TodoItemRequestBuilder requestBuilder;
    private TodoItemCrudService service;

    @BeforeEach
    void configureSystemUnderTest() {
        service = mock(TodoItemCrudService.class);

        MockMvc mockMvc = MockMvcBuilders
                .standaloneSetup(new TodoItemCrudController(messageSource, service))
                .setHandlerExceptionResolvers(exceptionResolver())
                .setLocaleResolver(fixedLocaleResolver())
                .setViewResolvers(jspViewResolver())
                .build();
        requestBuilder = new TodoItemRequestBuilder(mockMvc);
    }

    @Nested
    @DisplayName("Submit the create todo item form")
    class SubmitCreateTodoItemForm {

        private static final String FORM_OBJECT_ALIAS = "todoItem";
        private static final int MAX_LENGTH_DESCRIPTION = 1000;
        private static final int MAX_LENGTH_TITLE = 100;

        private CreateTodoItemFormDTO formObject;

        @Nested
        @DisplayName("When validation fails")
        class WhenValidationFails {

        }

        @Nested
        @DisplayName("When validation is successful")
        class WhenValidationIsSuccessful {

        }
    }
}

Third, we have to ensure that the system under test is working as expected when validation fails. We can write the required test methods by following these steps:

  1. Add the required constants to the WhenValidationFails class.
  2. Add a new setup method to the WhenValidationFails class and ensure that it's run before a test method is run. When we implement this method, we must the create the form object that's used by our test methods. Because we want ensure that the system under test is working as expected when an empty form is submitted, we have to create a new CreateTodoItemFormDTO object that has an empty title and description.
  3. Ensure that the system under test returns the HTTP status code 200.
  4. Verify that the system under test renders the form view.
  5. Ensure that the system under test displays an empty create todo item form.
  6. Verify that the system under test displays one validation error.
  7. Ensure that the system under test displays a validation error about an empty title.
  8. Verify 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.context.support.StaticMessageSource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.exceptionResolver;
import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.fixedLocaleResolver;
import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.jspViewResolver;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.emptyString;
import static org.hamcrest.Matchers.hasProperty;
import static org.hamcrest.Matchers.is;
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.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

public class TodoItemCrudControllerTest {

    private StaticMessageSource messageSource = new StaticMessageSource();
    private TodoItemRequestBuilder requestBuilder;
    private TodoItemCrudService service;

    @BeforeEach
    void configureSystemUnderTest() {
        service = mock(TodoItemCrudService.class);

        MockMvc mockMvc = MockMvcBuilders
                .standaloneSetup(new TodoItemCrudController(messageSource, service))
                .setHandlerExceptionResolvers(exceptionResolver())
                .setLocaleResolver(fixedLocaleResolver())
                .setViewResolvers(jspViewResolver())
                .build();
        requestBuilder = new TodoItemRequestBuilder(mockMvc);
    }

    @Nested
    @DisplayName("Submit the create todo item form")
    class SubmitCreateTodoItemForm {

        private static final String FORM_OBJECT_ALIAS = "todoItem";
        private static final int MAX_LENGTH_DESCRIPTION = 1000;
        private static final int MAX_LENGTH_TITLE = 100;

        private CreateTodoItemFormDTO formObject;

        @Nested
        @DisplayName("When validation fails")
        class WhenValidationFails {

            private static final String FORM_FIELD_NAME_DESCRIPTION = "description";
            private static final String FORM_FIELD_NAME_TITLE = "title";

            private static final String VALIDATION_ERROR_NOT_BLANK = "NotBlank";

            private static final String VIEW_NAME_FORM_VIEW = "todo-item/create";

            @BeforeEach
            void createFormObject() {
                formObject = new CreateTodoItemFormDTO();
                formObject.setDescription("");
                formObject.setTitle("");
            }

            @Test
            @DisplayName("Should return the HTTP status code OK (200)")
            void shouldReturnHttpStatusCodeOk() throws Exception {
                requestBuilder.create(formObject)
                        .andExpect(status().isOk());
            }

            @Test
            @DisplayName("Should render the form view")
            void shouldRenderFormView() throws Exception {
                requestBuilder.create(formObject)
                        .andExpect(view().name(VIEW_NAME_FORM_VIEW));
            }

            @Test
            @DisplayName("Should display an empty create todo item form")
            void shouldDisplayEmptyCreateTodoItemForm() throws Exception {
                requestBuilder.create(formObject)
                        .andExpect(model().attribute(FORM_OBJECT_ALIAS, allOf(
                                hasProperty(
                                        FORM_FIELD_NAME_DESCRIPTION, 
                                        is(emptyString())
                                ),
                                hasProperty(
                                        FORM_FIELD_NAME_TITLE, 
                                        is(emptyString())
                                )
                        )));
            }

            @Test
            @DisplayName("Should display one validation error")
            void shouldDisplayOneValidationError() throws Exception {
                requestBuilder.create(formObject)
                        .andExpect(model().attributeErrorCount(FORM_OBJECT_ALIAS, 1));
            }

            @Test
            @DisplayName("Should display a validation error about empty title")
            void shouldDisplayValidationErrorAboutEmptyTitle() throws Exception {
                requestBuilder.create(formObject)
                        .andExpect(model().attributeHasFieldErrorCode(
                                FORM_OBJECT_ALIAS,
                                FORM_FIELD_NAME_TITLE,
                                VALIDATION_ERROR_NOT_BLANK
                        ));
            }

            @Test
            @DisplayName("Shouldn't create a new todo item")
            void shouldNotCreateNewTodoItem() throws Exception {
                requestBuilder.create(formObject);
                verify(service, never()).create(any());
            }
        }

        //The other inner class is omitted
    }
}
The validation of the form object will fail if:

  • The title and description of the created todo item are null
  • The title and description of the created todo item are empty.
  • The title and description of the created todo item contain only whitespace characters.
  • The title and description of the created todo item are 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 lesson.

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 WhenValidationIsSuccessful class.
  2. Add a new setup method to the WhenValidationIsSuccessful class and ensure that it's run before a test method is run. When we implement this method, we must:
    • Create a form object that has a valid title and description.
    • Configure the feedback message that's shown to the user.
    • Ensure that the create() method of the TodoItemCrudService class returns the information of the created todo item.
  3. Verify that the system under test returns the HTTP status code 302.
  4. Ensure that the system under test redirects the HTTP request to the view todo item view.
  5. Verify that the system under test displays the correct flash message.
  6. Ensure that the system under test creates a new todo item with the correct description.
  7. Verify that the system under test creates a new todo item with 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.context.support.StaticMessageSource;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import static info.solidsoft.mockito.java8.AssertionMatcher.assertArg;
import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.exceptionResolver;
import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.fixedLocaleResolver;
import static net.petrikainulainen.springmvctest.junit5.web.WebTestConfig.jspViewResolver;
import static net.petrikainulainen.springmvctest.junit5.web.WebTestUtil.createStringWithLength;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.equalTo;
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.flash;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

public class TodoItemCrudControllerTest {

    private StaticMessageSource messageSource = new StaticMessageSource();
    private TodoItemRequestBuilder requestBuilder;
    private TodoItemCrudService service;

    @BeforeEach
    void configureSystemUnderTest() {
        service = mock(TodoItemCrudService.class);

        MockMvc mockMvc = MockMvcBuilders
                .standaloneSetup(new TodoItemCrudController(messageSource, service))
                .setHandlerExceptionResolvers(exceptionResolver())
                .setLocaleResolver(fixedLocaleResolver())
                .setViewResolvers(jspViewResolver())
                .build();
        requestBuilder = new TodoItemRequestBuilder(mockMvc);
    }

    @Nested
    @DisplayName("Submit the create todo item form")
    class SubmitCreateTodoItemForm {

        private static final String FORM_OBJECT_ALIAS = "todoItem";
        private static final int MAX_LENGTH_DESCRIPTION = 1000;
        private static final int MAX_LENGTH_TITLE = 100;

        private CreateTodoItemFormDTO formObject;

        //The other inner class is omitted

        @Nested
        @DisplayName("When validation is successful")
        class WhenValidationIsSuccessful {

            private static final String FEEDBACK_MESSAGE = "A new todo item was created";
            private static final String FEEDBACK_MESSAGE_KEY = "feedback.message.todoItem.created";

            private static final String FLASH_ATTRIBUTE_KEY_FEEDBACK_MESSAGE = "feedbackMessage";

            private static final String MODEL_ATTRIBUTE_NAME_ID = "id";
            private static final String VIEW_NAME_VIEW_TODO_ITEM_VIEW = "redirect:/todo-item/{id}";

            private static final Long ID = 1L;
            private static final String DESCRIPTION = createStringWithLength(MAX_LENGTH_DESCRIPTION);
            private static final String TITLE = createStringWithLength(MAX_LENGTH_TITLE);

            @BeforeEach
            void configureSystemUnderTest() {
                formObject = createFormObject();
                configureFeedbackMessage();
                returnCreatedTodoItem();
            }

            private CreateTodoItemFormDTO createFormObject() {
                CreateTodoItemFormDTO formObject = new CreateTodoItemFormDTO();
                formObject.setDescription(DESCRIPTION);
                formObject.setTitle(TITLE);
                return formObject;
            }

            private void configureFeedbackMessage() {
                messageSource.addMessage(
                        FEEDBACK_MESSAGE_KEY,
                        WebTestConfig.LOCALE,
                        FEEDBACK_MESSAGE
                );
            }

            private void returnCreatedTodoItem() {
                TodoItemDTO created = new TodoItemDTO();
                created.setId(ID);

                given(service.create(any())).willReturn(created);
            }

            @Test
            @DisplayName("Should return the HTTP status code found (302)")
            void shouldReturnHttpStatusCodeFound() throws Exception {
                requestBuilder.create(formObject)
                        .andExpect(status().isFound());
            }

            @Test
            @DisplayName("Should redirect the HTTP request to the view todo item view")
            void shouldRedirectHttpRequestToViewTodoItemView() throws Exception {
                requestBuilder.create(formObject)
                        .andExpect(view().name(VIEW_NAME_VIEW_TODO_ITEM_VIEW))
                        .andExpect(model().attribute(
                                MODEL_ATTRIBUTE_NAME_ID, equalTo(ID.toString())));
            }

            @Test
            @DisplayName("Should display the correct flash message")
            void shouldDisplayCorrectFlashMessage() throws Exception {
                requestBuilder.create(formObject)
                        .andExpect(flash().attribute(
                                FLASH_ATTRIBUTE_KEY_FEEDBACK_MESSAGE,
                                equalTo(FEEDBACK_MESSAGE)
                        ));
            }

            @Test
            @DisplayName("Should create a new todo item with the correct description")
            void shouldCreateNewTodoItemWithCorrectDescription() throws Exception {
                requestBuilder.create(formObject);

                verify(service, times(1)).create(assertArg(
                        todoItem -> assertThat(todoItem.getDescription())
                                .isEqualTo(DESCRIPTION)
                ));
            }

            @Test
            @DisplayName("Should create a new todo item with the correct title")
            void shouldCreateNewTodoItemWithCorrectTitle() throws Exception {
                requestBuilder.create(formObject);

                verify(service, times(1)).create(assertArg(
                        todoItem -> assertThat(todoItem.getTitle())
                                .isEqualTo(TITLE)
                ));
            }
        }
    }
}

We can now write unit tests for a controller method that submits a form. Let's summarize what we learned from this blog post.

Summary

This blog post has taught us six things:

  • We can configure the field values of the submitted form by using the param() method of the MockHttpServletRequestBuilder class.
  • When we have to ensure that the system under test displays X validation errors, we have to invoke the attributeErrorCount() method of the ModelResultMatchers class.
  • When we have to verify that the system under test displays the correct validation error, we have to use attributeHasFieldErrorCode() method of the ModelResultMatchers class.
  • When we have to ensure that fields of the rendered form contain correct information, we have to invoke the attribute() method of the ModelResultMatchers class.
  • When we have to verify that the HTTP request is redirected to the correct path, we have to use the name() method of the ViewResultMatchers class.
  • When we have to ensure that the system under test displays the correct flash message to the user, we have to invoke the attribute() method of the FlashAttributeResultMatchers class.

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

0 comments… add one

Leave a Reply