Writing Unit Tests for "Normal" Spring MVC Controllers - Rendering Data

After we have finished this lesson, we:

  • Can create feature methods which specify the expected behavior of “normal” Spring MVC controllers that render data.
  • 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 GET requests send to the path: ‘/task/{taskId}’. The name of this method is showTask(), and it is implemented by following these steps:

  1. Find the task from the database by invoking the findById() method of the TaskCrudService interface. Pass the task id, which is given as a path variable, as a method parameter.
  2. Put the found task to a model attribute called task.
  3. Return the name of the view (’task/view’) that renders the information of the found 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.stereotype.Controller;
 4import org.springframework.ui.Model;
 5import org.springframework.web.bind.annotation.PathVariable;
 6import org.springframework.web.bind.annotation.RequestMapping;
 7import org.springframework.web.bind.annotation.RequestMethod;
 8
 9import javax.validation.Valid;
10import java.util.List;
11import java.util.Locale;
12
13@Controller
14public class TaskCrudController {
15
16    private final TaskCrudService crudService;
17    private final MessageSource messageSource;
18
19    @Autowired
20    TaskCrudController(TaskCrudService crudService,
21                       MessageSource messageSource) {
22        this.crudService = crudService;
23        this.messageSource = messageSource;
24    }
25
26    @RequestMapping(value = "/task/{taskId}", method = RequestMethod.GET)
27    public String showTask(@PathVariable("taskId") Long taskId,
28                           Model model) {
29        TaskDTO task = crudService.findById(taskId);
30        model.addAttribute("task", task);
31        return "task/view";
32    }
33}

The TaskDTO class is a data transfer object that contains the information of a single task. Its source code looks as follows:

 1import java.time.ZonedDateTime;
 2import java.util.ArrayList;
 3import java.util.List;
 4
 5public class TaskDTO {
 6
 7    private PersonDTO assignee;
 8    private PersonDTO closer;
 9    private ZonedDateTime creationTime;
10    private PersonDTO creator;
11    private String description;
12    private Long id;
13    private ZonedDateTime modificationTime;
14    private PersonDTO modifier;
15    private TaskResolution resolution;
16    private TaskStatus status;
17    private List<TagDTO> tags = new ArrayList<>();
18    private String title;
19
20    //Getters and setters are omitted
21}

The PersonDTO class is a data transfer object that contains the information of a single person. Its source code looks as follows:

1public class PersonDTO {
2
3    private String name;
4    private Long userId;
5
6    //Getters and setters are omitted
7}

The TagDTO class is a data transfer object that contains the information of a single tag. Its source code looks as follows:

1public class TagDTO {
2
3    private Long id;
4    private String name;
5
6    //Getters and setters are omitted
7}

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 showTask() method by following these steps:

First, we have to add a new feature method to our specification class and define a variable that contains the response (a ResultActions object) returned by the tested controller method.

After we have added a new feature method to our specification class, 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 java.time.ZonedDateTime
 7
 8import static com.testwithspring.master.web.WebTestConfig.exceptionResolver
 9import static com.testwithspring.master.web.WebTestConfig.fixedLocaleResolver
10import static com.testwithspring.master.web.WebTestConfig.jspViewResolver
11
12@Category(UnitTest.class)
13class TaskCrudControllerSpec extends Specification {
14
15    private static final CREATION_TIME = ZonedDateTime.now().minusDays(4)
16    private static final MODIFICATION_TIME = CREATION_TIME.plusDays(2)
17
18    def messageSource = new StaticMessageSource()
19    def service = Mock(TaskCrudService)
20    def mockMvc = MockMvcBuilders
21            .standaloneSetup(new TaskCrudController(service, messageSource))
22            .setHandlerExceptionResolvers(exceptionResolver())
23            .setLocaleResolver(fixedLocaleResolver())
24            .setViewResolvers(jspViewResolver())
25            .build()
26
27    def 'Show task'() {
28
29        def response
30    }
31}

Second, we have to verify that the showTask() method is working as expected when the requested task is not found. We can add the required when and then blocks to our feature method by following these steps:

  1. Ensure that the TaskCrudService mock throws a NotFoundException when its findById() method is invoked by using the method parameter: 99L.
  2. Send a GET request to the path: ‘/task/99’ and store the returned ResultActions object in the response variable.
  3. Verify that the tested controller method returns the HTTP status code 404.
  4. Ensure that the tested controller method renders the 404 view.

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 java.time.ZonedDateTime
 7
 8import static com.testwithspring.master.web.WebTestConfig.exceptionResolver
 9import static com.testwithspring.master.web.WebTestConfig.fixedLocaleResolver
10import static com.testwithspring.master.web.WebTestConfig.jspViewResolver
11import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
12import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
13import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view
14
15@Category(UnitTest.class)
16class TaskCrudControllerSpec extends Specification {
17
18    private static final CREATION_TIME = ZonedDateTime.now().minusDays(4)
19    private static final MODIFICATION_TIME = CREATION_TIME.plusDays(2)
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 'Show task'() {
31
32        def response
33
34        when: 'No task is found'
35        service.findById(99L) >> {
36            throw new NotFoundException('')
37        }
38
39        and: 'A user tries to open the view task page'
40        response = mockMvc.perform(get('/task/{taskId}', 99L))
41
42        then: 'Should return the HTTP status code not found'
43        response.andExpect(status().isNotFound())
44
45        and: 'Should render the 404 view'
46        response.andExpect(view().name('error/404'))
47    }
48}

Third, we have to verify that the showTask() method is working as expected when the requested task is found. We can add the required when and then blocks to our feature method by following these steps:

  1. Ensure that the TaskCrudService mock returns a closed task that has one tag when its findById() method is invoked by using the method parameter: 1L.
  2. Send a GET request to the path: ‘/task/1’ and store the returned ResultActions object in the response variable.
  3. Verify that the tested controller method returns the HTTP status code 200.
  4. Ensure that the tested controller method renders the view task view.
  5. Verify that the tested controller method displays the information of the found task.
  6. Ensure that the tested controller method displays the information of one tag.
  7. Verify that the tested controller method displays the information of the correct tag.

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 java.time.ZonedDateTime
  7
  8import static com.testwithspring.master.web.WebTestConfig.exceptionResolver
  9import static com.testwithspring.master.web.WebTestConfig.fixedLocaleResolver
 10import static com.testwithspring.master.web.WebTestConfig.jspViewResolver
 11import static org.hamcrest.Matchers.*
 12import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
 13import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model
 14import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
 15import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view
 16
 17@Category(UnitTest.class)
 18class TaskCrudControllerSpec extends Specification {
 19
 20    private static final CREATION_TIME = ZonedDateTime.now().minusDays(4)
 21    private static final MODIFICATION_TIME = CREATION_TIME.plusDays(2)
 22
 23    def messageSource = new StaticMessageSource()
 24    def service = Mock(TaskCrudService)
 25    def mockMvc = MockMvcBuilders
 26            .standaloneSetup(new TaskCrudController(service, messageSource))
 27            .setHandlerExceptionResolvers(exceptionResolver())
 28            .setLocaleResolver(fixedLocaleResolver())
 29            .setViewResolvers(jspViewResolver())
 30            .build()
 31
 32    def 'Show task'() {
 33
 34        def response
 35
 36        when: 'No task is found'
 37        service.findById(99L) >> {
 38            throw new NotFoundException('')
 39        }
 40
 41        and: 'A user tries to open the view task page'
 42        response = mockMvc.perform(get('/task/{taskId}', 99L))
 43
 44        then: 'Should return the HTTP status code not found'
 45        response.andExpect(status().isNotFound())
 46
 47        and: 'Should render the 404 view'
 48        response.andExpect(view().name('error/404'))
 49
 50        when: 'A closed task is found and it has one tag'
 51        service.findById(1L) >> {
 52            new TaskDTOBuilder()
 53                    .withId(1L)
 54                    .withAssignee(new PersonDTO(
 55                            userId: 44L,
 56                            name: 'Anne Assignee'
 57                    ))
 58                    .withCreationTime(CREATION_TIME)
 59                    .withCreator(new PersonDTO(
 60                            userId: 99L,
 61                            name: 'John Doe'
 62                    ))
 63                    .withDescription('description')
 64                    .withModificationTime(MODIFICATION_TIME)
 65                    .withModifier(new PersonDTO(
 66                            userId: 33L,
 67                            name: 'Jane Doe'
 68                    ))
 69                    .withResolutionDone(new PersonDTO(
 70                            userId: 931L,
 71                            name: 'Chris Closer'
 72                    ))
 73                    .withTags(new TagDTO(id: 33L, name: 'testing'))
 74                    .withTitle('title')
 75                    .build()
 76        }
 77
 78        and: 'A user opens the view task page'
 79        response = mockMvc.perform(get('/task/{taskId}', 1L))
 80
 81        then: 'Should return the HTTP status code OK'
 82        response.andExpect(status().isOk())
 83
 84        and: 'Should render the view task view'
 85        response.andExpect(view().name('task/view'))
 86
 87        and: 'Should display the information of the found task'
 88        response.andExpect(model().attribute('task', allOf(
 89                hasProperty('assignee', allOf(
 90                        hasProperty('name', is('Anne Assignee')),
 91                        hasProperty('userId', is(44L))
 92                )),
 93                hasProperty('closer', allOf(
 94                        hasProperty('name', is('Chris Closer')),
 95                        hasProperty('userId', is(931L))
 96                )),
 97                hasProperty('creationTime', is(CREATION_TIME)),
 98                hasProperty('creator', allOf(
 99                        hasProperty('name', is('John Doe')),
100                        hasProperty('userId', is(99L))
101                )),
102                hasProperty('id', is(1L)),
103                hasProperty('modificationTime', is(MODIFICATION_TIME)),
104                hasProperty('modifier', allOf(
105                        hasProperty('name', is('Jane Doe')),
106                        hasProperty('userId', is(33L))
107                )),
108                hasProperty('title', is('title')),
109                hasProperty('description', is('description')),
110                hasProperty('status', is(TaskStatus.CLOSED)),
111                hasProperty('resolution', is(TaskResolution.DONE))
112        )))
113
114        and: 'Should display one tag of the found task'
115        response.andExpect(model().attribute('task',
116                hasProperty('tags', hasSize(1))
117        ))
118
119        and: 'Should display the tag of the found task'
120        response.andExpect(model().attribute('task',
121                hasProperty('tags', contains(
122                        allOf(
123                                hasProperty('id', is(33L)),
124                                hasProperty('name', is('testing'))
125                        )
126                ))
127        ))
128    }
129}

The TaskDTOBuilder class is a test data builder that creates new TaskDTO objects. We use a test data builder because the TaskDTO class is not just a data container, and we don’t want to put the object creation logic to our specification classes.

Also, 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 renders data. Also, it provides additional material that was left out from this lesson.

Finally, there are three things I want to point out:

  • Our feature method has two test cases that use different when and then blocks. This allows us to identify the tested feature and specify how the feature should behave in different situations.
  • We have divided the when and then blocks into different sections by using the and label and each section has a specific responsibility. For example, it might invoke the tested controller method or specify the condition which verifies that the tested controller method renders the correct view.
  • If we add good descriptions to our feature method, our feature method becomes an executable specification that documents the behavior of the system under specification.

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

Summary

This lesson has taught us three things:

  • We can test different scenarios by adding when and then blocks to our feature method.
  • If a block has multiple responsibilities, we should divide it into different sections by using the and label.
  • We should add good descriptions to our feature method because this allows us to create executable specifications that document the behavior of the system under specification.

Get the source code from Github