Writing Unit Tests for a REST API - Writing Data

After we have finished this lesson, we

  • know how we can transform objects into JSON.
  • Understand how we can send JSON documents to the tested controller.
  • Know how we can write assertions for the validation errors returned to the client.

Introduction to System Under Test

We have to write unit tests for a 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 tested controller method looks as follows:

 1import org.springframework.beans.factory.annotation.Autowired;
 2import org.springframework.http.HttpStatus;
 3import org.springframework.web.bind.annotation.RequestBody;
 4import org.springframework.web.bind.annotation.RequestMapping;
 5import org.springframework.web.bind.annotation.RequestMethod;
 6import org.springframework.web.bind.annotation.ResponseStatus;
 7import org.springframework.web.bind.annotation.RestController;
 8
 9import javax.validation.Valid;
10
11@RestController
12@RequestMapping("/api/task")
13public class TaskCrudController {
14
15    private final TaskCrudService crudService;
16
17    @Autowired
18    TaskCrudController(TaskCrudService crudService) {
19        this.crudService = crudService;
20    }
21
22    @RequestMapping(method = RequestMethod.POST)
23    @ResponseStatus(HttpStatus.CREATED)
24    public TaskDTO create(@Valid @RequestBody TaskFormDTO task) {
25        return crudService.create(task);
26    }
27}

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
20   	//Getters and setters are omitted
21}

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}

Let’s move on and create a utility method that we use when we send JSON documents to the tested controller.

Transforming Objects Into JSON

Before we can write unit tests for our REST API, we have to create a utility class that can transform objects into JSON bytes which can be send to the tested controller method.

We can do this by following these steps:

  1. Create a new object mother class.
  2. Add a new factory method into the created class. This method converts the object given as method parameter to JSON bytes by using the ObjectMapper object that is returned by the objectMapper() method of the WebTestConfig class.

The source code of our object mother class looks as follows:

 1import com.fasterxml.jackson.databind.ObjectMapper;
 2
 3import java.io.IOException;
 4
 5import static com.testwithspring.starter.spring.web.WebTestConfig.objectMapper;
 6
 7public final class WebTestUtil {
 8
 9    private WebTestUtil() {}
10
11    public static byte[] convertObjectToJsonBytes(Object object) throws IOException {
12        ObjectMapper mapper = objectMapper();
13        return mapper.writeValueAsBytes(object);
14    }
15}

Next, we have to configure the system under test.

Configuring the System Under Test

We will use the configuration that was described in an earlier lesson of this topic . That is why I won’t repeat myself here. However, I want to point out that we include a custom error handler and a custom ObjectMapper in the system under test.

After we have configured the system under test, the source code of our test class looks as follows:

 1import de.bechte.junit.runners.context.HierarchicalContextRunner;
 2import org.junit.Before;
 3import org.junit.experimental.categories.Category;
 4import org.junit.runner.RunWith;
 5import org.springframework.test.web.servlet.MockMvc;
 6import org.springframework.test.web.servlet.setup.MockMvcBuilders;
 7
 8import static com.testwithspring.starter.spring.web.WebTestConfig.objectMapperHttpMessageConverter;
 9import static org.mockito.Mockito.mock;
10
11@RunWith(HierarchicalContextRunner.class)
12@Category(UnitTest.class)
13public class TaskCrudControllerTest {
14
15    private TaskCrudService crudService;
16    private MockMvc mockMvc;
17
18    @Before
19    public void configureSystemUnderTest() {
20        crudService = mock(TaskCrudService.class);
21
22        TaskCrudController controller = new TaskCrudController(crudService);
23        mockMvc = MockMvcBuilders.standaloneSetup(controller)
24                .setControllerAdvice(new TaskErrorHandler())
25                .setMessageConverters(objectMapperHttpMessageConverter())
26                .build();
27    }
28}

Let’s find out how we can write unit tests which ensure that the tested controller method is working as expected when validation fails.

Scenario 1: Validation Fails

Now we will write unit tests which verify that the tested controller method is working as expected when a client tries to create a new task that has no title and description. We can write these unit tests by following these steps:

First, we have to create the class hierarchy that we use when we configure our test cases and write our unit tests. After we have created this class hierarchy, the source code of our test class looks as follows:

 1import de.bechte.junit.runners.context.HierarchicalContextRunner;
 2import org.junit.Before;
 3import org.junit.experimental.categories.Category;
 4import org.junit.runner.RunWith;
 5import org.springframework.test.web.servlet.MockMvc;
 6import org.springframework.test.web.servlet.setup.MockMvcBuilders;
 7
 8import static com.testwithspring.starter.spring.web.WebTestConfig.objectMapperHttpMessageConverter;
 9import static org.mockito.Mockito.mock;
10
11@RunWith(HierarchicalContextRunner.class)
12@Category(UnitTest.class)
13public class TaskCrudControllerTest {
14
15    private TaskCrudService crudService;
16    private MockMvc mockMvc;
17
18    @Before
19    public void configureSystemUnderTest() {
20        crudService = mock(TaskCrudService.class);
21
22        TaskCrudController controller = new TaskCrudController(crudService);
23        mockMvc = MockMvcBuilders.standaloneSetup(controller)
24                .setControllerAdvice(new TaskErrorHandler())
25                .setMessageConverters(objectMapperHttpMessageConverter())
26                .build();
27    }
28
29    public class Create {
30
31        private TaskFormDTO input;
32
33        public class WhenFieldsAreEmpty {
34
35        }
36    }
37}

As we can see, we added a TaskFormDTO field into the Create class. This ensures that we have to declare this field only once.

Second, we have to add a setup method into the WhenFieldsAreEmpty class. This method creates an empty TaskFormDTO object and stores the created object to the input field.

After we have written this method, the source code of our test class looks as follows:

 1import de.bechte.junit.runners.context.HierarchicalContextRunner;
 2import org.junit.Before;
 3import org.junit.experimental.categories.Category;
 4import org.junit.runner.RunWith;
 5import org.springframework.test.web.servlet.MockMvc;
 6import org.springframework.test.web.servlet.setup.MockMvcBuilders;
 7
 8import static com.testwithspring.starter.spring.web.WebTestConfig.objectMapperHttpMessageConverter;
 9import static org.mockito.Mockito.mock;
10
11@RunWith(HierarchicalContextRunner.class)
12@Category(UnitTest.class)
13public class TaskCrudControllerTest {
14
15    private TaskCrudService crudService;
16    private MockMvc mockMvc;
17
18    @Before
19    public void configureSystemUnderTest() {
20        crudService = mock(TaskCrudService.class);
21
22        TaskCrudController controller = new TaskCrudController(crudService);
23        mockMvc = MockMvcBuilders.standaloneSetup(controller)
24                .setControllerAdvice(new TaskErrorHandler())
25                .setMessageConverters(objectMapperHttpMessageConverter())
26                .build();
27    }
28
29    public class Create {
30
31        private TaskFormDTO input;
32
33        public class WhenFieldsAreEmpty {
34
35            @Before
36            public void createEmptyInput() {
37                input = new TaskFormDTO();
38                input.setDescription("");
39                input.setTitle("");
40            }
41        }
42    }
43}

Third, we have to write five unit tests for the tested controller method:

  1. We have to verify that it returns the HTTP status code 400.
  2. We have to ensure that it returns the validation errors as JSON.
  3. We have to verify that it returns one validation error.
  4. We have to ensure that it returns a validation error about an empty title.
  5. We have to verify that it doesn’t create a new task.

After we have written these unit tests the source code of our test class looks as follows:

  1import de.bechte.junit.runners.context.HierarchicalContextRunner;
  2import org.junit.Before;
  3import org.junit.Test;
  4import org.junit.experimental.categories.Category;
  5import org.junit.runner.RunWith;
  6import org.springframework.http.MediaType;
  7import org.springframework.test.web.servlet.MockMvc;
  8import org.springframework.test.web.servlet.setup.MockMvcBuilders;
  9
 10import static com.testwithspring.starter.spring.web.WebTestConfig.objectMapperHttpMessageConverter;
 11import static org.hamcrest.Matchers.hasSize;
 12import static org.hamcrest.Matchers.is;
 13import static org.mockito.Matchers.isA;
 14import static org.mockito.Mockito.mock;
 15import static org.mockito.Mockito.never;
 16import static org.mockito.Mockito.verify;
 17import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 18import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
 19import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 20import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 21
 22@RunWith(HierarchicalContextRunner.class)
 23@Category(UnitTest.class)
 24public class TaskCrudControllerTest {
 25
 26    private TaskCrudService crudService;
 27    private MockMvc mockMvc;
 28
 29    @Before
 30    public void configureSystemUnderTest() {
 31        crudService = mock(TaskCrudService.class);
 32
 33        TaskCrudController controller = new TaskCrudController(crudService);
 34        mockMvc = MockMvcBuilders.standaloneSetup(controller)
 35                .setControllerAdvice(new TaskErrorHandler())
 36                .setMessageConverters(objectMapperHttpMessageConverter())
 37                .build();
 38    }
 39
 40    public class Create {
 41
 42        private TaskFormDTO input;
 43
 44        public class WhenFieldsAreEmpty {
 45
 46            @Before
 47            public void createEmptyInput() {
 48                input = new TaskFormDTO();
 49                input.setDescription("");
 50                input.setTitle("");
 51            }
 52
 53            @Test
 54            public void shouldReturnHttpStatusCodeBadRequest() throws Exception {
 55                mockMvc.perform(post("/api/task")
 56                        .contentType(MediaType.APPLICATION_JSON_UTF8)
 57                        .content(WebTestUtil.convertObjectToJsonBytes(input))
 58                )
 59                        .andExpect(status().isBadRequest());
 60            }
 61
 62            @Test
 63            public void shouldReturnValidationErrorsAsJson() throws Exception {
 64                mockMvc.perform(post("/api/task")
 65                        .contentType(MediaType.APPLICATION_JSON_UTF8)
 66                        .content(WebTestUtil.convertObjectToJsonBytes(input))
 67                )
 68                        .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8));
 69            }
 70
 71            @Test
 72            public void shouldReturnOneValidationError() throws Exception {
 73                mockMvc.perform(post("/api/task")
 74                        .contentType(MediaType.APPLICATION_JSON_UTF8)
 75                        .content(WebTestUtil.convertObjectToJsonBytes(input))
 76                )
 77                        .andExpect(jsonPath("$.fieldErrors", hasSize(1)));
 78            }
 79
 80            @Test
 81            public void shouldReturnValidationErrorAboutEmptyTitle() throws Exception {
 82                mockMvc.perform(post("/api/task")
 83                        .contentType(MediaType.APPLICATION_JSON_UTF8)
 84                        .content(WebTestUtil.convertObjectToJsonBytes(input))
 85                )
 86                        .andExpect(jsonPath("$.fieldErrors[0].field",
 87                                is("title")
 88                        ))
 89                        .andExpect(jsonPath("$.fieldErrors[0].errorCode",
 90                                is("NotBlank")
 91                        ));
 92            }
 93
 94            @Test
 95            public void shouldNotCreateTask() throws Exception {
 96                mockMvc.perform(post("/api/task")
 97                        .contentType(MediaType.APPLICATION_JSON_UTF8)
 98                        .content(WebTestUtil.convertObjectToJsonBytes(input))
 99                );
100
101                verify(crudService, never()).create(isA(TaskFormDTO.class));
102            }
103        }
104    }
105}

If you have to write a unit test which ensures that multiple validation errors are returned, you cannot use the technique described here because the validation errors are returned in random order. For example, if the created task has too long title and description, you can write the required assertions by using the following code:

1.andExpect(jsonPath("$.fieldErrors[?(@.field == 'title')].errorCode",
2		contains("Size")
3))
4.andExpect(jsonPath("$.fieldErrors[?(@.field == 'description')].errorCode",
5		contains("Size")
6));

Additional Reading:

Next, we will write unit tests which ensure that the tested controller method is working as expected when validation is successful.

Scenario 2: Validation Is Successful

Now we will write unit tests which verify that the tested controller method is working as expected when we create a new task by using the following information:

  • The title of the task is: ’title’.
  • The description of the task is ‘description’.

We can write these tests by following these steps:

First, we have to add the required class hierarchy into our test class. After we have created this class hierarchy, the source code of our test class looks as follows:

 1import de.bechte.junit.runners.context.HierarchicalContextRunner;
 2import org.junit.Before;
 3import org.junit.experimental.categories.Category;
 4import org.junit.runner.RunWith;
 5import org.springframework.test.web.servlet.MockMvc;
 6import org.springframework.test.web.servlet.setup.MockMvcBuilders;
 7
 8import static com.testwithspring.starter.spring.web.WebTestConfig.objectMapperHttpMessageConverter;
 9import static org.mockito.Mockito.mock;
10
11@RunWith(HierarchicalContextRunner.class)
12@Category(UnitTest.class)
13public class TaskCrudControllerTest {
14
15    private TaskCrudService crudService;
16    private MockMvc mockMvc;
17
18    @Before
19    public void configureSystemUnderTest() {
20        crudService = mock(TaskCrudService.class);
21
22        TaskCrudController controller = new TaskCrudController(crudService);
23        mockMvc = MockMvcBuilders.standaloneSetup(controller)
24                .setControllerAdvice(new TaskErrorHandler())
25                .setMessageConverters(objectMapperHttpMessageConverter())
26                .build();
27    }
28
29    public class Create {
30
31        private TaskFormDTO input;
32
33        public class WhenAllFieldsAreValid {
34
35        }
36    }
37}

Second, we have to configure our test cases by following these steps:

  1. Add a private createValidInput() method into the WhenAllFieldsAreValid class. This method creates a valid TaskFormDTO object.
  2. Add a private returnCreatedTask() method into the WhenAllFieldsAreValid class. This method creates the returned TaskDTO object by using a test data builder and ensures that the TaskCrudService mock returns the created object when its create() method is invoked and a TaskFormDTO object is passed as a method parameter.
  3. Add a setup method into the WhenAllFieldsAreValid class and configure our test cases.

After we have configured our test cases, the source code of our test class class looks as follows:

 1import de.bechte.junit.runners.context.HierarchicalContextRunner;
 2import org.junit.Before;
 3import org.junit.experimental.categories.Category;
 4import org.junit.runner.RunWith;
 5import org.springframework.test.web.servlet.MockMvc;
 6import org.springframework.test.web.servlet.setup.MockMvcBuilders;
 7
 8import static com.testwithspring.starter.spring.web.WebTestConfig.objectMapperHttpMessageConverter;
 9import static org.mockito.BDDMockito.given;
10import static org.mockito.Matchers.isA;
11import static org.mockito.Mockito.mock;
12
13@RunWith(HierarchicalContextRunner.class)
14@Category(UnitTest.class)
15public class TaskCrudControllerTest {
16
17    private TaskCrudService crudService;
18    private MockMvc mockMvc;
19
20    @Before
21    public void configureSystemUnderTest() {
22        crudService = mock(TaskCrudService.class);
23
24        TaskCrudController controller = new TaskCrudController(crudService);
25        mockMvc = MockMvcBuilders.standaloneSetup(controller)
26                .setControllerAdvice(new TaskErrorHandler())
27                .setMessageConverters(objectMapperHttpMessageConverter())
28                .build();
29    }
30
31    public class Create {
32
33        private TaskFormDTO input;
34
35        public class WhenAllFieldsAreValid {
36
37            @Before
38            public void configureTestCases() {
39                createValidInput();
40                returnCreatedTask();
41            }
42
43            private void createValidInput() {
44                input = new TaskFormDTO();
45                input.setTitle("title");
46                input.setDescription("description");
47            }
48
49            private void returnCreatedTask() {
50                TaskDTO created = new TaskDTOBuilder()
51                        .withId(1L)
52                        .withCreator(99L)
53                        .withTitle("title")
54                        .withDescription("description")
55                        .withStatusOpen()
56                        .build();
57                given(crudService.create(isA(TaskFormDTO.class)))
58						.willReturn(created);
59            }
60        }
61    }
62}

Third, we have to write six unit tests for the create() method:

  1. We have to verify that it returns the HTTP status code 201.
  2. We have to ensure that it returns the created task as JSON.
  3. We have to verify that it returns the correct information.
  4. We have to ensure that it creates a task with null id.
  5. We have to verify that it creates a task by using the correct title.
  6. We have to ensure that it creates a task by using the correct description.

After we have written these unit tests, the source code of our test class looks as follows:

  1import de.bechte.junit.runners.context.HierarchicalContextRunner;
  2import org.junit.Before;
  3import org.junit.Test;
  4import org.junit.experimental.categories.Category;
  5import org.junit.runner.RunWith;
  6import org.springframework.http.MediaType;
  7import org.springframework.test.web.servlet.MockMvc;
  8import org.springframework.test.web.servlet.setup.MockMvcBuilders;
  9
 10import static com.testwithspring.starter.spring.web.WebTestConfig.objectMapperHttpMessageConverter;
 11import static info.solidsoft.mockito.java8.AssertionMatcher.assertArg;
 12import static org.assertj.core.api.Assertions.assertThat;
 13import static org.hamcrest.Matchers.is;
 14import static org.hamcrest.Matchers.nullValue;
 15import static org.mockito.BDDMockito.given;
 16import static org.mockito.Matchers.isA;
 17import static org.mockito.Mockito.mock;
 18import static org.mockito.Mockito.times;
 19import static org.mockito.Mockito.verify;
 20import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
 21import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
 22import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
 23import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
 24
 25@RunWith(HierarchicalContextRunner.class)
 26@Category(UnitTest.class)
 27public class TaskCrudControllerTest {
 28
 29    private TaskCrudService crudService;
 30    private MockMvc mockMvc;
 31
 32    @Before
 33    public void configureSystemUnderTest() {
 34        crudService = mock(TaskCrudService.class);
 35
 36        TaskCrudController controller = new TaskCrudController(crudService);
 37        mockMvc = MockMvcBuilders.standaloneSetup(controller)
 38                .setControllerAdvice(new TaskErrorHandler())
 39                .setMessageConverters(objectMapperHttpMessageConverter())
 40                .build();
 41    }
 42
 43    public class Create {
 44
 45        private TaskFormDTO input;
 46
 47        public class WhenAllFieldsAreValid {
 48
 49            @Before
 50            public void configureTestCases() {
 51                createValidInput();
 52                returnCreatedTask();
 53            }
 54
 55            private void createValidInput() {
 56                input = new TaskFormDTO();
 57                input.setTitle("title");
 58                input.setDescription("description");
 59            }
 60
 61            private void returnCreatedTask() {
 62                TaskDTO created = new TaskDTOBuilder()
 63                        .withId(1L)
 64                        .withCreator(99L)
 65                        .withTitle("title")
 66                        .withDescription("description")
 67                        .withStatusOpen()
 68                        .build();
 69                given(crudService.create(isA(TaskFormDTO.class)))
 70                        .willReturn(created);
 71            }
 72
 73            @Test
 74            public void shouldReturnHttpStatusCodeCreated() throws Exception {
 75                mockMvc.perform(post("/api/task")
 76                        .contentType(MediaType.APPLICATION_JSON_UTF8)
 77                        .content(WebTestUtil.convertObjectToJsonBytes(input))
 78                )
 79                        .andExpect(status().isCreated());
 80            }
 81
 82            @Test
 83            public void shouldReturnCreatedTaskAsJson() throws Exception {
 84                mockMvc.perform(post("/api/task")
 85                        .contentType(MediaType.APPLICATION_JSON_UTF8)
 86                        .content(WebTestUtil.convertObjectToJsonBytes(input))
 87                )
 88                        .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8));
 89            }
 90
 91            @Test
 92            public void shouldReturnCorrectInformation() throws Exception {
 93                mockMvc.perform(post("/api/task")
 94                        .contentType(MediaType.APPLICATION_JSON_UTF8)
 95                        .content(WebTestUtil.convertObjectToJsonBytes(input))
 96                )
 97                        .andExpect(jsonPath("$.assigneeId", nullValue()))
 98                        .andExpect(jsonPath("$.closerId", nullValue()))
 99                        .andExpect(jsonPath("$.creatorId", is(99)))
100                        .andExpect(jsonPath("$.id", is(1)))
101                        .andExpect(jsonPath("$.title", is("title")))
102                        .andExpect(jsonPath("$.description", is("description")))
103                        .andExpect(jsonPath("$.status",
104								is(TaskStatus.OPEN.toString())
105						))
106                        .andExpect(jsonPath("$.resolution", nullValue()));
107            }
108
109            @Test
110            public void shouldCreateTaskWithoutId() throws Exception {
111                mockMvc.perform(post("/api/task")
112                        .contentType(MediaType.APPLICATION_JSON_UTF8)
113                        .content(WebTestUtil.convertObjectToJsonBytes(input))
114                );
115
116                verify(crudService, times(1)).create(assertArg(
117                        task -> assertThat(task.getId()).isNull()
118                ));
119            }
120
121            @Test
122            public void shouldCreateTaskWithCorrectTitle() throws Exception {
123                mockMvc.perform(post("/api/task")
124                        .contentType(MediaType.APPLICATION_JSON_UTF8)
125                        .content(WebTestUtil.convertObjectToJsonBytes(input))
126                );
127
128                verify(crudService, times(1)).create(assertArg(
129                        task -> assertThat(task.getTitle()).isEqualTo("title")
130                ));
131            }
132
133            @Test
134            public void shouldCreateTaskWithCorrectDescription() throws Exception {
135                mockMvc.perform(post("/api/task")
136                        .contentType(MediaType.APPLICATION_JSON_UTF8)
137                        .content(WebTestUtil.convertObjectToJsonBytes(input))
138                );
139
140                verify(crudService, times(1)).create(assertArg(
141                        task -> assertThat(task.getDescription())
142								.isEqualTo("description")
143                ));
144            }
145        }
146    }
147}

Let’s summarize what we learned from this lesson.

Summary

This lesson has taught us four things:

  • We can convert objects into JSON by using a factory method and our custom ObjectMapper.
  • We can set the content type of a HTTP request by using the contentType() method of the MockHttpServletRequestBuilder class.
  • We can send JSON documents to the tested controller method by using the content() method of the MockHttpServletRequestBuilder class.
  • If we want to write assertions for the validation errors returned to the client, we must remember that these errors are returned in random order.

Get the source code from Github