Writing Unit Tests for a Spring REST API - Writing Data
After we have finished this lesson, we:
- Can create feature methods which specify the expected behavior of a REST API that writes information to the database.
- 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 create feature methods which specify the expected behavior of a REST API that reads information from the database
Introduction to System Under Specification
We have to specify the behavior of the controller method that processes POST requests send to the path: ‘/api/task’. The name of this method is create(), and it is implemented by following these steps:
- Read the information of the created task from the request body and validate the information.
- If validation errors are found, return the HTTP status code 400 and describe the validation errors in the response body.
- Create a new task and return the information of the created task. Remember to return the HTTP status code 201.
The source code of the system under specification looks as follows:
1import org.springframework.beans.factory.annotation.Autowired;
2import org.springframework.http.HttpStatus;
3import org.springframework.security.core.annotation.AuthenticationPrincipal;
4import org.springframework.web.bind.annotation.RequestBody;
5import org.springframework.web.bind.annotation.RequestMapping;
6import org.springframework.web.bind.annotation.RequestMethod;
7import org.springframework.web.bind.annotation.ResponseStatus;
8import org.springframework.web.bind.annotation.RestController;
9
10import javax.validation.Valid;
11
12@RestController
13@RequestMapping("/api/task")
14public class TaskCrudController {
15
16 private final TaskCrudService crudService;
17
18 @Autowired
19 TaskCrudController(TaskCrudService crudService) {
20 this.crudService = crudService;
21 }
22
23 @RequestMapping(method = RequestMethod.POST)
24 @ResponseStatus(HttpStatus.CREATED)
25 public TaskDTO create(@Valid @RequestBody TaskFormDTO task,
26 @AuthenticationPrincipal LoggedInUser loggedInUser) {
27 return crudService.create(task, loggedInUser);
28 }
29}
The TaskFormDTO class contains the information 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 this information. Its source code 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 //Getters and setters are omitted
20}
The validation errors are processed by the TaskErrorHandler class. Its implementation doesn’t belong to the scope of this course, but it is useful to know what kind of a JSON document is returned to the client if validation fails. For example, if the client tries to create a new task that has no title, the following JSON document is returned to the client:
1{
2 "fieldErrors":[
3 {
4 "field":"title",
5 "errorCode":"NotBlank"
6 }
7 ]
8}
After the create() method has created a new task, it returns the information of the created task back to the client. For example, the returned JSON document could look as follows:
1{
2 "creationTime":"2017-07-30T11:41:28+03:00[Europe/Helsinki]",
3 "creator":{
4 "name":"John Doe",
5 "userId":99
6 },
7 "description":"description",
8 "id":1,
9 "modificationTime":"2017-07-31T11:41:28+03:00[Europe/Helsinki]",
10 "modifier":{
11 "name":"John Doe",
12 "userId":99
13 },
14 "status":"OPEN",
15 "tags":[],
16 "title":"title"
17}
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 create() method by following these steps:
First, we have to add a new feature method to our specification class and define variables that contain the input (a TaskFormDTO object) send to the tested controller method and the response (a ResultActions object) returned by the tested controller method.
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.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 .parseLocalDateTimeFromUTCDateTime('2017-07-30T11:41:28')
13
14 private static final CREATION_TIME_STRING = TestDateTimeBuilder
15 .transformUTCDateToLocalDateTime('2017-07-30T11:41:28')
16
17 def service = Mock(TaskCrudService)
18 def mockMvc = MockMvcBuilders
19 .standaloneSetup(new TaskCrudController(service))
20 .setControllerAdvice(new TaskErrorHandler())
21 .setLocaleResolver(fixedLocaleResolver())
22 .setMessageConverters(objectMapperHttpMessageConverter())
23 .build()
24
25 def 'Create a new task'() {
26
27 def input
28 def response
29 }
30}
given label here because our setup block is so simple that it doesn’t require an additional description.
Second, we have to ensure that the create() method is working as expected when a user tries to create an empty task. We can do this by following these steps:
- Create a new
TaskFormDTOobject that has empty title and description. - Try to create a new task and store the returned
ResultActionsobject in theresponsevariable. - Ensure that the tested controller method returns the HTTP status code 400.
- Verify that the tested controller method returns the validation errors as JSON.
- Ensure that the tested controller method returns one validation error.
- Verify that the tested controller method returns validation error about an empty title.
- Ensure 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.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.post
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 .parseLocalDateTimeFromUTCDateTime('2017-07-30T11:41:28')
20
21 private static final CREATION_TIME_STRING = TestDateTimeBuilder
22 .transformUTCDateToLocalDateTime('2017-07-30T11:41:28')
23
24 def service = Mock(TaskCrudService)
25 def mockMvc = MockMvcBuilders
26 .standaloneSetup(new TaskCrudController(service))
27 .setControllerAdvice(new TaskErrorHandler())
28 .setLocaleResolver(fixedLocaleResolver())
29 .setMessageConverters(objectMapperHttpMessageConverter())
30 .build()
31
32 def 'Create a new task'() {
33
34 def input
35 def response
36
37 when: 'A user tries to create a new task by using empty title and description'
38 input = new TaskFormDTO(title: '', description: '')
39 response = mockMvc.perform(post('/api/task')
40 .contentType(MediaType.APPLICATION_JSON_UTF8)
41 .content(ObjectIntoJsonConverter.convertObjectIntoJsonBytes(input))
42 )
43
44 then: 'Should return the HTTP status code bad request'
45 response.andExpect(status().isBadRequest())
46
47 and: 'Should return validation errors as JSON'
48 response.andExpect(
49 content().contentType(MediaType.APPLICATION_JSON_UTF8)
50 )
51
52 and: 'Should return one validation error'
53 response.andExpect(jsonPath('$.fieldErrors',
54 hasSize(1)
55 ))
56
57 and: 'Should return validation error about an empty title'
58 response.andExpect(jsonPath('$.fieldErrors[0].field',
59 is('title')
60 ))
61 .andExpect(jsonPath('$.fieldErrors[0].errorCode',
62 is('NotBlank')
63 ))
64
65 and: 'Should not create a new task'
66 0 * service.create(_ as TaskFormDTO, _ as LoggedInUser)
67 }
68}
Third, we have to ensure that the create() method is working as expected when a user creates a new task by using valid information. We can do this by following these steps:
- Create a new
TaskFormDTOobject that has valid title and description. - Create a new task and store the returned
ResultActionsobject in theresponsevariable. - Ensure that the tested controller method creates a new task by using the correct information and return the created task.
- Verify that the tested controller method returns the HTTP status code 201.
- Ensure that the tested controller method returns the created task as JSON.
- Verify that the tested controller method returns the information of the created task.
- Ensure that the tested controller method returns a task that has no tags.
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.hamcrest.Matchers.nullValue
11import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
12import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
13import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
14import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
15
16@Category(UnitTest.class)
17class TaskCrudControllerSpec extends Specification {
18
19 private static final CREATION_TIME = TestDateTimeBuilder
20 .parseDateTime('2017-07-30T11:41:28')
21
22 private static final CREATION_TIME_STRING = TestDateTimeBuilder
23 .transformUTCDateToLocalDateTime('2017-07-30T11: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 'Create a new task'() {
34
35 def input
36 def response
37
38 when: 'A user tries to create a new task by using empty title and description'
39 input = new TaskFormDTO(title: '', description: '')
40 response = mockMvc.perform(post('/api/task')
41 .contentType(MediaType.APPLICATION_JSON_UTF8)
42 .content(ObjectIntoJsonConverter.convertObjectIntoJsonBytes(input))
43 )
44
45 then: 'Should return the HTTP status code bad request'
46 response.andExpect(status().isBadRequest())
47
48 and: 'Should return validation errors as JSON'
49 response.andExpect(
50 content().contentType(MediaType.APPLICATION_JSON_UTF8)
51 )
52
53 and: 'Should return one validation error'
54 response.andExpect(jsonPath('$.fieldErrors',
55 hasSize(1)
56 ))
57
58 and: 'Should return validation error about an empty title'
59 response.andExpect(jsonPath('$.fieldErrors[0].field',
60 is('title')
61 ))
62 .andExpect(jsonPath('$.fieldErrors[0].errorCode',
63 is('NotBlank')
64 ))
65
66 and: 'Should not create a new task'
67 0 * service.create(_ as TaskFormDTO, _ as LoggedInUser)
68
69 when: 'A User creates a new task by using valid information'
70 input = new TaskFormDTO(title: 'title', description: 'description')
71 response = mockMvc.perform(post('/api/task')
72 .contentType(MediaType.APPLICATION_JSON_UTF8)
73 .content(ObjectIntoJsonConverter.convertObjectIntoJsonBytes(input))
74 )
75
76 then: 'Should create a new task by using the correct information and return the created task'
77 1 * service.create({ TaskFormDTO saved ->
78 saved.id == null
79 saved.description == 'description'
80 saved.title == 'title'
81 } as TaskFormDTO, _ as LoggedInUser ) >> new TaskDTOBuilder()
82 .withId(1L)
83 .withCreationTime(CREATION_TIME)
84 .withCreator(new PersonDTO(userId: 99L, name: 'John Doe'))
85 .withDescription('description')
86 .withModificationTime(CREATION_TIME)
87 .withModifier(new PersonDTO(userId: 99L, name: 'John Doe'))
88 .withTitle('title')
89 .withStatusOpen()
90 .build()
91
92 and: 'Should return the HTTP status code created'
93 response.andExpect(status().isCreated())
94
95 and: 'Should return the created task as json'
96 response.andExpect(
97 content().contentType(MediaType.APPLICATION_JSON_UTF8)
98 )
99
100 and: 'Should return the information of the created task'
101 response.andExpect(jsonPath('$.assignee', nullValue()))
102 .andExpect(jsonPath('$.closer', nullValue()))
103 .andExpect(jsonPath('$.creationTime',
104 is(CREATION_TIME_STRING)
105 ))
106 .andExpect(jsonPath('$.creator.userId', is(99)))
107 .andExpect(jsonPath('$.creator.name', is('John Doe')))
108 .andExpect(jsonPath('$.id', is(1)))
109 .andExpect(jsonPath('$.modificationTime',
110 is(CREATION_TIME_STRING)
111 ))
112 .andExpect(jsonPath('$.modifier.userId', is(99)))
113 .andExpect(jsonPath('$.modifier.name', is('John Doe')))
114 .andExpect(jsonPath('$.title', is('title')))
115 .andExpect(jsonPath('$.description', is('description')))
116 .andExpect(jsonPath('$.status',
117 is(TaskStatus.OPEN.toString())
118 ))
119 .andExpect(jsonPath('$.resolution', nullValue()))
120
121 and: 'Should return a task that has no tags'
122 response.andExpect(jsonPath('$.tags', hasSize(0)))
123 }
124}
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 saves information to the database and returns the saved information as JSON. Also, it provides additional material that was left out from this lesson.
Also, 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 - Forms .
We can now write unit tests for a REST API that saves information to the database and returns the saved information as JSON. Let’s summarize what we learned from this lesson.
Summary
This lesson has taught us two things:
- If our
setupblock is so simple that it doesn’t require an additional description, we shouldn’t use thegivenlabel. - If our feature method becomes too long, we can write shorter feature methods by adding different scenarios to different feature methods.