Writing Unit Tests for “Normal” Spring MVC Controllers – Forms

After we have finished this lesson, we:

  • Can create feature methods which specify the expected behavior of “normal” Spring MVC controllers which process form submissions.
  • Understand how we can document the system under specification by using the setup, when, and then blocks.

Introduction to System Under Specification

We have to specify the expected behavior of the controller method that processes POST requests send to the path: ‘/task/create’. The name of this method is processCreateTaskForm(), and it is implemented by following these steps:

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

The source code of the system under specification looks as follows:

 1import org.springframework.beans.factory.annotation.Autowired;
 2import org.springframework.context.MessageSource;
 3import org.springframework.security.core.annotation.AuthenticationPrincipal;
 4import org.springframework.stereotype.Controller;
 5import org.springframework.validation.BindingResult;
 6import org.springframework.web.bind.annotation.ModelAttribute;
 7import org.springframework.web.bind.annotation.RequestMapping;
 8import org.springframework.web.bind.annotation.RequestMethod;
 9import org.springframework.web.servlet.mvc.support.RedirectAttributes;
10
11import javax.validation.Valid;
12import java.util.Locale;
13
14@Controller
15public class TaskCrudController {
16
17    private final TaskCrudService crudService;
18    private final MessageSource messageSource;
19
20    @Autowired
21    TaskCrudController(TaskCrudService crudService,
22                       MessageSource messageSource) {
23        this.crudService = crudService;
24        this.messageSource = messageSource;
25    }
26
27    @RequestMapping(value = "/task/create", method = RequestMethod.POST)
28    public String processCreateTaskForm(@Valid @ModelAttribute("task") TaskFormDTO formObject,
29                                        BindingResult bindingResult,
30                                        RedirectAttributes redirectAttributes,
31                                        Locale currentLocale,
32                                        @AuthenticationPrincipal LoggedInUser loggedInUser) {
33        if (bindingResult.hasErrors()) {
34            return "task/create";
35        }
36
37        TaskDTO created = crudService.create(formObject, loggedInUser);
38
39        addFeedbackMessage(redirectAttributes,
40                "feedback.message.task.created",
41                currentLocale,
42                created.getTitle()
43        );
44        redirectAttributes.addAttribute("taskId", created.getId());
45
46        return "redirect:/task/{taskId}";
47    }
48
49    private void addFeedbackMessage(RedirectAttributes attributes,
50                                    String messageCode,
51                                    Locale currentLocale,
52                                    Object... messageParameters) {
53        String localizedFeedbackMessage = messageSource.getMessage(messageCode,
54                messageParameters,
55                currentLocale
56        );
57        attributes.addFlashAttribute("feedbackMessage",
58                localizedFeedbackMessage
59        );
60    }
61}

The TaskFormDTO class contains the information of the form object that is used to create new tasks and update the information of existing tasks. It also declares the validation rules that are used to validate the form object. The source code of the TaskFormDTO class looks as follows:

 1import org.hibernate.validator.constraints.NotBlank;
 2
 3import javax.validation.constraints.Size;
 4
 5public class TaskFormDTO {
 6
 7    private Long id;
 8
 9    @Size(max = 500)
10    private String description;
11
12    @NotBlank
13    @Size(max = 100)
14    private String title;
15
16    public TaskFormDTO() {
17
18    }
19
20    //Getters and setters are omitted
21}

Next, we will find out how we can write the feature method which specifies the expected behavior of the system under specification.

Specifying the Expected Behavior

We can write the feature method which specifies the behavior of the system under specification by following these steps:

First, we have to add a new feature method to our specification class. After we have added a new feature method to our specification class, its source code looks as follows:

 1import org.junit.experimental.categories.Category
 2import org.springframework.context.support.StaticMessageSource
 3import org.springframework.test.web.servlet.setup.MockMvcBuilders
 4import spock.lang.Specification
 5
 6import static com.testwithspring.master.web.WebTestConfig.exceptionResolver
 7import static com.testwithspring.master.web.WebTestConfig.fixedLocaleResolver
 8import static com.testwithspring.master.web.WebTestConfig.jspViewResolver
 9@Category(UnitTest.class)
10class TaskCrudControllerSpec extends Specification {
11
12    def messageSource = new StaticMessageSource()
13    def service = Mock(TaskCrudService)
14    def mockMvc = MockMvcBuilders
15            .standaloneSetup(new TaskCrudController(service, messageSource))
16            .setHandlerExceptionResolvers(exceptionResolver())
17            .setLocaleResolver(fixedLocaleResolver())
18            .setViewResolvers(jspViewResolver())
19            .build()
20
21    def 'Create a new task'() {
22
23    }
24}

Second, we have to add a setup block to our feature method by following these steps:

  1. Ensure that the MessageSource object contains the feedback message that is shown after a new task has been created.
  2. Define a variable that contains the response (a ResultActions object) returned by the tested controller method.

After we have added a setup block to our feature method, the source code of our specification class looks as follows:

 1import org.junit.experimental.categories.Category
 2import org.springframework.context.support.StaticMessageSource
 3import org.springframework.test.web.servlet.setup.MockMvcBuilders
 4import spock.lang.Specification
 5
 6import static com.testwithspring.master.web.WebTestConfig.exceptionResolver
 7import static com.testwithspring.master.web.WebTestConfig.fixedLocaleResolver
 8import static com.testwithspring.master.web.WebTestConfig.jspViewResolver
 9@Category(UnitTest.class)
10class TaskCrudControllerSpec extends Specification {
11
12    def messageSource = new StaticMessageSource()
13    def service = Mock(TaskCrudService)
14    def mockMvc = MockMvcBuilders
15            .standaloneSetup(new TaskCrudController(service, messageSource))
16            .setHandlerExceptionResolvers(exceptionResolver())
17            .setLocaleResolver(fixedLocaleResolver())
18            .setViewResolvers(jspViewResolver())
19            .build()
20
21    def 'Create a new task'() {
22
23        given: 'The message source contains the feedback message'
24        messageSource.addMessage('feedback.message.task.created',
25                WebTestConfig.LOCALE,
26                'Task created'
27        )
28
29        def response
30    }
31}
I added the given label to the setup block of our feature method because the setup block has multiple responsibilities. Also, I used a description that describes the most important responsibility of our setup block. I decided to not use the and label because the second responsibility of our setup block is obvious (it defines the variable that contains the response returned by the tested controller method).

Third, we have to ensure that the processCreateTaskForm() method is working as expected when a user submits an empty form. We can do this by following these steps:

  1. Submit an empty create task form and store the returned ResultActions object in the response variable.
  2. Ensure that the tested controller method returns the HTTP status code 200.
  3. Verify that the tested controller method renders the create task view.
  4. Ensure that the tested controller method shows a validation error about an empty title.
  5. Verify that the tested controller method displays an empty create task form.
  6. Ensure that the tested controller method doesn’t set the id of the form object.
  7. Verify that the tested controller method doesn’t create a new task.

After we have written the required when and then blocks, the source code of our specification class looks as follows:

 1import org.junit.experimental.categories.Category
 2import org.springframework.context.support.StaticMessageSource
 3import org.springframework.test.web.servlet.setup.MockMvcBuilders
 4import spock.lang.Specification
 5
 6import static com.testwithspring.master.web.WebTestConfig.exceptionResolver
 7import static com.testwithspring.master.web.WebTestConfig.fixedLocaleResolver
 8import static com.testwithspring.master.web.WebTestConfig.jspViewResolver
 9import static org.hamcrest.Matchers.allOf
10import static org.hamcrest.Matchers.hasProperty
11import static org.hamcrest.Matchers.is
12import static org.hamcrest.Matchers.nullValue
13import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
14import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model
15import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
16import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view
17
18@Category(UnitTest.class)
19class TaskCrudControllerSpec extends Specification {
20
21    def messageSource = new StaticMessageSource()
22    def service = Mock(TaskCrudService)
23    def mockMvc = MockMvcBuilders
24            .standaloneSetup(new TaskCrudController(service, messageSource))
25            .setHandlerExceptionResolvers(exceptionResolver())
26            .setLocaleResolver(fixedLocaleResolver())
27            .setViewResolvers(jspViewResolver())
28            .build()
29
30    def 'Create a new task'() {
31
32        given: 'The message source contains the feedback message'
33        messageSource.addMessage('feedback.message.task.created',
34                WebTestConfig.LOCALE,
35                'Task created'
36        )
37
38        def response
39
40        when: 'A user submits an empty form'
41        response = mockMvc.perform(post('/task/create')
42                .param('description', '')
43                .param('title', '')
44        )
45
46        then: 'Should return the HTTP status code OK'
47        response.andExpect(status().isOk())
48
49        and: 'Should render the create task view'
50        response.andExpect(view().name('task/create'))
51
52        and: 'Should show a validation error about an empty title'
53        response.andExpect(model().attributeHasFieldErrorCode('task',
54                'title',
55                is('NotBlank')
56        ))
57
58        and: 'Should show empty title and description'
59        response.andExpect(model().attribute('task', allOf(
60                hasProperty('description', is('')),
61                hasProperty('title', is(''))
62        )))
63
64        and: 'Should not modify the id field'
65        response.andExpect(model().attribute('task',
66                hasProperty('id', nullValue())
67        ))
68
69        and: 'Should not create a new task'
70        0 * service.create(_ as TaskFormDTO, _ as LoggedInUser)
71    }
72}

Fourth, we have to ensure that the processCreateTaskForm() method is working as expected when a user submits the create task form by using valid information. We can do this by following these steps:

  1. Submit the create task form by using valid information and store the returned ResultActions object in the response variable.
  2. Ensure that the tested controller method creates a new task by using the correct information and return the created task.
  3. Verify that the tested controller method returns the HTTP status code 302.
  4. Ensure that the tested controller method redirects the user to the view task view.
  5. Verify that the tested controller method creates the feedback message which is shown to the user.

After we have written the required when and then blocks, the source code of our specification class looks as follows:

  1import org.junit.experimental.categories.Category
  2import org.springframework.context.support.StaticMessageSource
  3import org.springframework.test.web.servlet.setup.MockMvcBuilders
  4import spock.lang.Specification
  5
  6import static com.testwithspring.master.web.WebTestConfig.exceptionResolver
  7import static com.testwithspring.master.web.WebTestConfig.fixedLocaleResolver
  8import static com.testwithspring.master.web.WebTestConfig.jspViewResolver
  9import static org.hamcrest.Matchers.allOf
 10import static org.hamcrest.Matchers.hasProperty
 11import static org.hamcrest.Matchers.is
 12import static org.hamcrest.Matchers.nullValue
 13import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
 14import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.flash
 15import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model
 16import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
 17import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view
 18
 19@Category(UnitTest.class)
 20class TaskCrudControllerSpec extends Specification {
 21
 22    def messageSource = new StaticMessageSource()
 23    def service = Mock(TaskCrudService)
 24    def mockMvc = MockMvcBuilders
 25            .standaloneSetup(new TaskCrudController(service, messageSource))
 26            .setHandlerExceptionResolvers(exceptionResolver())
 27            .setLocaleResolver(fixedLocaleResolver())
 28            .setViewResolvers(jspViewResolver())
 29            .build()
 30
 31    def 'Create a new task'() {
 32
 33        given: 'The message source contains the feedback message'
 34        messageSource.addMessage('feedback.message.task.created',
 35                WebTestConfig.LOCALE,
 36                'Task created'
 37        )
 38
 39        def response
 40
 41        when: 'A user submits an empty form'
 42        response = mockMvc.perform(post('/task/create')
 43                .param('description', '')
 44                .param('title', '')
 45        )
 46
 47        then: 'Should return the HTTP status code OK'
 48        response.andExpect(status().isOk())
 49
 50        and: 'Should render the create task view'
 51        response.andExpect(view().name('task/create'))
 52
 53        and: 'Should show a validation error about an empty title'
 54        response.andExpect(model().attributeHasFieldErrorCode('task',
 55                'title',
 56                is('NotBlank')
 57        ))
 58
 59        and: 'Should show empty title and description'
 60        response.andExpect(model().attribute('task', allOf(
 61                hasProperty('description', is('')),
 62                hasProperty('title', is(''))
 63        )))
 64
 65        and: 'Should not modify the id field'
 66        response.andExpect(model().attribute('task',
 67                hasProperty('id', nullValue())
 68        ))
 69
 70        and: 'Should not create a new task'
 71        0 * service.create(_ as TaskFormDTO, _ as LoggedInUser)
 72
 73        when: 'A user submits the create task form by using valid information'
 74        response = mockMvc.perform(post('/task/create')
 75                .param('description', 'This is a new task')
 76                .param('title', 'Create a new task')
 77        )
 78
 79        then: 'Should create a new task by using the correct information and return the created task'
 80        1 * service.create({ TaskFormDTO saved ->
 81            saved.id == null
 82            saved.description == 'This is a new task'
 83            saved.title == 'Create a new task'
 84        } as TaskFormDTO, _ as LoggedInUser) >> new TaskDTO(
 85                id: 1L,
 86                title: 'Create a new task'
 87        )
 88
 89        and: 'Should return the HTTP status code found'
 90        response.andExpect(status().isFound())
 91
 92        and: 'Should redirect the user to the view task view'
 93        response.andExpect(view().name('redirect:/task/{taskId}'))
 94                .andExpect(model().attribute('taskId', is('1')))
 95
 96        and: 'Should show feedback message to the user'
 97        response.andExpect(flash().attribute('feedbackMessage',
 98                'Task created'
 99        ))
100    }
101}

If this feature method looks strange to you, you should take a look at the following lesson:

This lesson describes how you can write unit tests for a controller method that processes form submissions. Also, it provides additional material that was left out from this lesson.

Finally, the real feature method tests a few other scenarios as well. The benefit of this approach is that the created documentation looks logical because one feature method describes every possible scenario. On the other hand, the downside of this approach is that the feature method can be quite long. If this is a problem to you, you can always add different use cases to different feature methods.

We can now write unit tests for controller methods that process form submissions. Let’s summarize what we learned from this lesson.

Summary

This lesson has taught us two things:

  • If our setup block has multiple responsibilities, we should describe the most important responsibility by using the given label and split the block into smaller sections by using the and label.
  • If our feature method becomes too long, we can write shorter feature methods by adding different scenarios to different feature methods.

Get the source code from Github