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, andthenblocks.
This lesson assumes that:
- You can write unit tests for Spring web applications by using JUnit
- You are familiar with Spock Framework
- You can create test doubles with Spock Framework
- You can create test data with Groovy
- You can configure the Spring MVC Test framework when you are writing unit tests with Spock Framework
- You can write unit tests for “normal” controllers that render data
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:
- Ensure that the
TaskCrudServicemock throws aNotFoundExceptionwhen itsfindById()method is invoked by using the method parameter:99L. - Send a
GETrequest to the path: ‘/api/task/99’ and store the returnedResultActionsobject in theresponsevariable. - 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:
- Ensure that the
TaskCrudServicemock returns a closed task that has one tag when itsfindById()method is invoked by using the method parameter:1L. - Send a
GETrequest to the path: ‘/api/task/1’ and store the returnedResultActionsobject in theresponsevariable. - Verify that the tested controller method returns the HTTP status code 200.
- Ensure that the tested controller method returns the found task as JSON.
- Verify that the tested controller method returns the information of the found task.
- Ensure that the tested controller method returns the information of one tag.
- 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
whenandthenblocks to our feature method. - If a block has multiple responsibilities, we should divide it into different sections by using the
andlabel. - 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.