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.
This lesson assumes that:
- You can create test data by using factory methods and test data builders
- You can write assertions with Hamcrest
- You can create test doubles with Mockito
- You can configure unit tests which verify that a REST API is working as expected
- You can write unit tests for controller methods methods that render information
- You can write unit tests for REST API that returns JSON documents
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:
- 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 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}
Additional Reading:
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:
- Create a new object mother class.
- Add a new factory method into the created class. This method converts the object given as method parameter to JSON bytes by using the
ObjectMapperobject that is returned by theobjectMapper()method of theWebTestConfigclass.
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:
- We have to verify that it returns the HTTP status code 400.
- We have to ensure that it returns the validation errors as JSON.
- We have to verify that it returns one validation error.
- We have to ensure that it returns a validation error about an empty title.
- 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:
- Add a private
createValidInput()method into theWhenAllFieldsAreValidclass. This method creates a validTaskFormDTOobject. - Add a private
returnCreatedTask()method into theWhenAllFieldsAreValidclass. This method creates the returnedTaskDTOobject by using a test data builder and ensures that theTaskCrudServicemock returns the created object when itscreate()method is invoked and aTaskFormDTOobject is passed as a method parameter. - Add a setup method into the
WhenAllFieldsAreValidclass 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:
- We have to verify that it returns the HTTP status code 201.
- We have to ensure that it returns the created task as JSON.
- We have to verify that it returns the correct information.
- We have to ensure that it creates a task with null id.
- We have to verify that it creates a task by using the correct title.
- 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}
Additional Reading:
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 theMockHttpServletRequestBuilderclass. - We can send JSON documents to the tested controller method by using the
content()method of theMockHttpServletRequestBuilderclass. - 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.