Writing Unit Tests for a Spring REST API - Reading Data

After we have finished this lesson, we:

  • Can create feature methods which specify the expected behavior of controller methods which read data from the database and return the found data as JSON.
  • 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: ‘/api/task/{taskId}’. The name of this method is findById(), and it simply returns the information of the task that is found from the database.

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

 1import org.springframework.beans.factory.annotation.Autowired;
 2import org.springframework.web.bind.annotation.PathVariable;
 3import org.springframework.web.bind.annotation.RequestMapping;
 4import org.springframework.web.bind.annotation.RequestMethod;
 5import org.springframework.web.bind.annotation.RestController;
 6
 7@RestController
 8@RequestMapping("/api/task")
 9public class TaskCrudController {
10
11    private final TaskCrudService crudService;
12
13    @Autowired
14    TaskCrudController(TaskCrudService crudService) {
15        this.crudService = crudService;
16    }
17
18    @RequestMapping(value = "{taskId}", method = RequestMethod.GET)
19    public TaskDTO findById(@PathVariable("taskId") Long taskId) {
20        return crudService.findById(taskId);
21    }
22}

If a closed task is found from the database and this task has one tag, the findById() method returns the following JSON document back to the client:

 1{
 2	"assignee": {
 3		"name":"Anne Assignee",
 4		"userId":44
 5	},
 6	"closer": {
 7		"name":"Chris Closer",
 8		"userId":931
 9	},
10	"creationTime":"2017-07-30T11:41:28+03:00[Europe/Helsinki]",
11	"creator":{
12		"name":"John Doe",
13		"userId":99
14	},
15	"description":"description",
16	"id":1,
17	"modificationTime":"2017-07-31T11:41:28+03:00[Europe/Helsinki]",
18	"modifier":{
19		"name":"Jane Doe",
20		"userId":33
21	},
22	"resolution":"DONE",
23	"status":"CLOSED",
24	"tags":[
25		{
26			"id":33,
27			"name":"testing"
28		}
29	],
30	"title":"title"
31}

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 findById() 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.test.web.servlet.setup.MockMvcBuilders
 3import spock.lang.Specification
 4
 5import static com.testwithspring.master.web.WebTestConfig.fixedLocaleResolver
 6import static com.testwithspring.master.web.WebTestConfig.objectMapperHttpMessageConverter
 7
 8@Category(UnitTest.class)
 9class TaskCrudControllerSpec extends Specification {
10
11    private static final CREATION_TIME = TestDateTimeBuilder
12            .parseDateTime('2017-07-30T11:41:28')
13
14    private static final CREATION_TIME_STRING = TestDateTimeBuilder
15            .appendOffsetAndZoneIdToDateTime('2017-07-30T11:41:28')
16
17    private static final MODIFICATION_TIME = TestDateTimeBuilder
18            .parseDateTime('2017-07-31T11:41:28')
19
20    private static final MODIFICATION_TIME_STRING = TestDateTimeBuilder
21            .appendOffsetAndZoneIdToDateTime('2017-07-31T11:41:28')
22
23    def service = Mock(TaskCrudService)
24    def mockMvc = MockMvcBuilders
25            .standaloneSetup(new TaskCrudController(service))
26            .setControllerAdvice(new TaskErrorHandler())
27            .setLocaleResolver(fixedLocaleResolver())
28            .setMessageConverters(objectMapperHttpMessageConverter())
29            .build()
30
31    def 'Find one task'() {
32
33        def response
34    }
35}

As we can see, our specification class declares constants by using the TestDateTimeBuilder class. This class provides factory methods that allow us to create date and time information that uses the system’s default timezone. In other words, this class ensures that we can run our unit tests in different time zones without making any changes to our test data.

If you want to get more information about this, you should take a look at the lesson: Writing Integration Tests for a REST API – Reading Data .

Second, we have to verify that the findById() 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: ‘/api/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.

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.test.web.servlet.setup.MockMvcBuilders
 3import spock.lang.Specification
 4
 5import static com.testwithspring.master.web.WebTestConfig.fixedLocaleResolver
 6import static com.testwithspring.master.web.WebTestConfig.objectMapperHttpMessageConverter
 7import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
 8import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
 9
10@Category(UnitTest.class)
11class TaskCrudControllerSpec extends Specification {
12
13    private static final CREATION_TIME = TestDateTimeBuilder
14            .parseDateTime('2017-07-30T11:41:28')
15
16    private static final CREATION_TIME_STRING = TestDateTimeBuilder
17            .appendOffsetAndZoneIdToDateTime('2017-07-30T11:41:28')
18
19    private static final MODIFICATION_TIME = TestDateTimeBuilder
20            .parseDateTime('2017-07-31T11:41:28')
21
22    private static final MODIFICATION_TIME_STRING = TestDateTimeBuilder
23            .appendOffsetAndZoneIdToDateTime('2017-07-31T11:41:28')
24
25    def service = Mock(TaskCrudService)
26    def mockMvc = MockMvcBuilders
27            .standaloneSetup(new TaskCrudController(service))
28            .setControllerAdvice(new TaskErrorHandler())
29            .setLocaleResolver(fixedLocaleResolver())
30            .setMessageConverters(objectMapperHttpMessageConverter())
31            .build()
32
33    def 'Find one task'() {
34
35        def response
36
37        when: 'No task is found'
38        service.findById(99L) >> { throw new NotFoundException('') }
39
40        and: 'A user tries to get the task'
41        response = mockMvc.perform(get('/api/task/{taskId}', 99L))
42
43        then: 'Should return the HTTP status code not found'
44        response.andExpect(status().isNotFound())
45    }
46}

Third, we have to verify that the findById() 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: ‘/api/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 returns the found task as JSON.
  5. Verify that the tested controller method returns the information of the found task.
  6. Ensure that the tested controller method returns the information of one tag.
  7. Verify that the tested controller method returns the information of the found 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.http.MediaType
  3import org.springframework.test.web.servlet.setup.MockMvcBuilders
  4import spock.lang.Specification
  5
  6import static com.testwithspring.master.web.WebTestConfig.fixedLocaleResolver
  7import static com.testwithspring.master.web.WebTestConfig.objectMapperHttpMessageConverter
  8import static org.hamcrest.Matchers.hasSize
  9import static org.hamcrest.Matchers.is
 10import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
 11import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
 12import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
 13import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
 14
 15@Category(UnitTest.class)
 16class TaskCrudControllerSpec extends Specification {
 17
 18    private static final CREATION_TIME = TestDateTimeBuilder
 19            .parseDateTime('2017-07-30T11:41:28')
 20
 21    private static final CREATION_TIME_STRING = TestDateTimeBuilder
 22            .appendOffsetAndZoneIdToDateTime('2017-07-30T11:41:28')
 23
 24    private static final MODIFICATION_TIME = TestDateTimeBuilder
 25            .parseDateTime('2017-07-31T11:41:28')
 26
 27    private static final MODIFICATION_TIME_STRING = TestDateTimeBuilder
 28            .appendOffsetAndZoneIdToDateTime('2017-07-31T11:41:28')
 29
 30    def service = Mock(TaskCrudService)
 31    def mockMvc = MockMvcBuilders
 32            .standaloneSetup(new TaskCrudController(service))
 33            .setControllerAdvice(new TaskErrorHandler())
 34            .setLocaleResolver(fixedLocaleResolver())
 35            .setMessageConverters(objectMapperHttpMessageConverter())
 36            .build()
 37
 38    def 'Find one task'() {
 39
 40        def response
 41
 42        when: 'No task is found'
 43        service.findById(99L) >> { throw new NotFoundException('') }
 44
 45        and: 'A user tries to get the task'
 46        response = mockMvc.perform(get('/api/task/{taskId}', 99L))
 47
 48        then: 'Should return the HTTP status code not found'
 49        response.andExpect(status().isNotFound())
 50
 51        when: 'A closed task is found and it has one tag'
 52        service.findById(1L) >> new TaskDTOBuilder()
 53                .withId(1L)
 54                .withAssignee(new PersonDTO(userId: 44L, name: 'Anne Assignee'))
 55                .withCreationTime(CREATION_TIME)
 56                .withCreator(new PersonDTO(userId: 99L, name: 'John Doe'))
 57                .withDescription('description')
 58                .withModificationTime(MODIFICATION_TIME)
 59                .withModifier(new PersonDTO(userId: 33L, name: 'Jane Doe'))
 60                .withResolutionDone(new PersonDTO(userId: 931L, name: 'Chris Closer'))
 61                .withTags(new TagDTO(id: 33L, name: 'testing'))
 62                .withTitle('title')
 63                .build()
 64
 65        and: 'A user gets the task'
 66        response = mockMvc.perform(get('/api/task/{taskId}', 1L))
 67
 68        then: 'Should return the HTTP status code OK'
 69        response.andExpect(status().isOk())
 70
 71        and: 'Should return the found task as JSON'
 72        response.andExpect(content().contentType(
 73                MediaType.APPLICATION_JSON_UTF8
 74        ))
 75
 76        and: 'Should return the information of the found task'
 77        response.andExpect(jsonPath('$.assignee.userId', is(44)))
 78                .andExpect(jsonPath('$.assignee.name', is('Anne Assignee')))
 79                .andExpect(jsonPath('$.closer.userId', is(931)))
 80                .andExpect(jsonPath('$.closer.name', is('Chris Closer')))
 81                .andExpect(jsonPath('$.creationTime', is(CREATION_TIME_STRING)))
 82                .andExpect(jsonPath('$.creator.userId', is(99)))
 83                .andExpect(jsonPath('$.creator.name', is('John Doe')))
 84                .andExpect(jsonPath('$.id', is(1)))
 85                .andExpect(jsonPath('$.modificationTime',
 86                        is(MODIFICATION_TIME_STRING)
 87                ))
 88                .andExpect(jsonPath('$.modifier.userId', is(33)))
 89                .andExpect(jsonPath('$.modifier.name', is('Jane Doe')))
 90                .andExpect(jsonPath('$.id', is(1)))
 91                .andExpect(jsonPath('$.title', is('title')))
 92                .andExpect(jsonPath('$.description', is('description')))
 93                .andExpect(jsonPath('$.status',
 94                        is(TaskStatus.CLOSED.toString())
 95                ))
 96                .andExpect(jsonPath('$.resolution',
 97                        is(TaskResolution.DONE.toString())
 98                ))
 99
100        and: 'Should return a task that has one tag'
101        response.andExpect(jsonPath('$.tags', hasSize(1)))
102
103        and: 'Should return the tag of the found task'
104        response.andExpect(jsonPath('$.tags[0].id', is(33)))
105                .andExpect(jsonPath('$.tags[0].name', is('testing')))
106    }
107}

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 reads information from the database and returns the found information as JSON. Also, it provides additional material that was left out from this lesson.

Finally, keep in mind that the advice given in an earlier lesson of this topic is valid for this feature method as well. If you want to refresh your memory, you should take a look at the lesson: Writing Unit Tests for “Normal” Spring MVC Controllers - Rendering Data .

We can now write unit tests for a REST API that reads information from the database. Let’s summarize what we learned from this lesson.

Summary

This lesson has taught us four 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.
  • If our REST API returns date and time information, we should create our test data by using the system’s default timezone. This ensures that we can run our unit tests in different time zones without making any changes to our test data.

Get the source code from Github