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, and then blocks.

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:

  1. Read the information of the created task from the request body and validate the information.
  2. If validation errors are found, return the HTTP status code 400 and describe the validation errors in the response body.
  3. 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}
I didn’t use the 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:

  1. Create a new TaskFormDTO object that has empty title and description.
  2. Try to create a new task and store the returned ResultActions object in the response variable.
  3. Ensure that the tested controller method returns the HTTP status code 400.
  4. Verify that the tested controller method returns the validation errors as JSON.
  5. Ensure that the tested controller method returns one validation error.
  6. Verify that the tested controller method returns validation error about an empty title.
  7. 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:

  1. Create a new TaskFormDTO object that has valid title and description.
  2. Create a new task and store the returned ResultActions object in the response variable.
  3. Ensure that the tested controller method creates a new task by using the correct information and return the created task.
  4. Verify that the tested controller method returns the HTTP status code 201.
  5. Ensure that the tested controller method returns the created task as JSON.
  6. Verify that the tested controller method returns the information of the created task.
  7. 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 setup block is so simple that it doesn’t require an additional description, we shouldn’t use the given 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