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.
- You are familiar with JUnit 5
- You are familiar with the Spring MVC Test Framework
- You understand how you can configure the system under test
- You know how you can send HTTP requests to the system under test
- You can write unit tests for a Spring MVC controller which renders the information of a single item or renders a list
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:
- If the submitted form has validation errors, return the name of the form view ('todo-item/create').
- Save the created todo item to the database by invoking the
create()
method of theTodoItemCrudService
class. - 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.
- 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 aStatusResultMatchers
object which allows us to write assertions for the returned HTTP status. - The
view()
method returns aViewResultMatchers
object which allows us to write assertions for the rendered view. - The
model()
method returns aModelResultMatchers
object which allows us to write assertions for the Spring MVC model. - The
flash()
method returns aFlashAttributeResultMatchers
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:
- Send a
POST
request to the path: '/todo-item' by invoking theperform()
method of theMockMvc
class. Remember to return theResultActions
object that's returned by theperform()
method. - Configure the field values of the submitted form by using the
param()
method of theMockHttpServletRequestBuilder
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:
- 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. - Add an inner class called
WhenValidationFails
to theSubmitFormThatCreatesNewTodoItems
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
WhenValidationIsSuccessful
to theSubmitFormThatCreatesNewTodoItems
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:
- Declare the constants which are used by the test methods found from the
WhenValidationFails
andWhenValidationIsSuccessful
inner classes. - Add a
private
field to theSubmitFormThatCreatesNewTodoItems
class. This field is calledformObject
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:
- Add the required constants to the
WhenValidationFails
class. - 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 newCreateTodoItemFormDTO
object that has an empty title and description. - Ensure that the system under test returns the HTTP status code 200.
- Verify that the system under test renders the form view.
- Ensure that the system under test displays an empty create todo item form.
- Verify that the system under test displays one validation error.
- Ensure that the system under test displays a validation error about an empty title.
- 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 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
WhenValidationIsSuccessful
class. - 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 theTodoItemCrudService
class returns the information of the created todo item.
- Verify that the system under test returns the HTTP status code 302.
- Ensure that the system under test redirects the HTTP request to the view todo item view.
- Verify that the system under test displays the correct flash message.
- Ensure that the system under test creates a new todo item with the correct description.
- 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 theMockHttpServletRequestBuilder
class. - When we have to ensure that the system under test displays X validation errors, we have to invoke the
attributeErrorCount()
method of theModelResultMatchers
class. - When we have to verify that the system under test displays the correct validation error, we have to use
attributeHasFieldErrorCode()
method of theModelResultMatchers
class. - When we have to ensure that fields of the rendered form contain correct information, we have to invoke the
attribute()
method of theModelResultMatchers
class. - When we have to verify that the HTTP request is redirected to the correct path, we have to use the
name()
method of theViewResultMatchers
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 theFlashAttributeResultMatchers
class.
P.S. You can get the example application of this blog post from Github.