Writing Unit Tests for a REST API With Kotlin and JUnit 5: Reading Data

After we have finished this lesson, we understand how we can write unit tests for a REST API that returns data as Json by using Kotlin and JUnit 5.

Get the source code from Github

Introduction to the System Under Test

During this lesson we will write unit tests for a Spring MVC REST API that returns the information of a task as Json. When the system under test gets the requested information, it follows these steps:

First, the findById() function of the TaskCrudController class gets the returned information by invoking the findById() function of the TaskCrudService class.

If the requested information is found, our REST API returns the found information as Json. On the other hand, if the requested information isn’t found, our REST API returns the HTTP status code 404.

The source code of the TaskCrudController class 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")
 9open class TaskCrudController(
10        @Autowired private val service: TaskCrudService
11) {
12
13    @RequestMapping(value = ["{id}"], method = [RequestMethod.GET])
14    fun findById(@PathVariable("id") id: Long): TaskDTO {
15        return service.findById(id)
16    }
17}

The public data model of our REST API looks as follows:

 1enum class TaskResolution {
 2    DUPLICATE,
 3    DONE,
 4    WONT_DO
 5}
 6
 7enum class TaskStatus {
 8    CLOSED,
 9    IN_PROGRESS,
10    OPEN
11}
12
13data class PersonDTO(val name: String, val userId: Long)
14
15data class TaskTagDTO(val id: Long, val name: String)
16
17data class TaskDTO(val assignee: PersonDTO?,
18                   val closer: PersonDTO?,
19                   val creator: PersonDTO,
20                   val description: String,
21                   val id: Long,
22                   val modifier: PersonDTO,
23                   val resolution: TaskResolution?,
24                   val status: TaskStatus,
25                   val tags: List<TaskTagDTO>,
26                   val title: String)

Second, the findById() function of the TaskCrudService class gets the requested information by following these steps:

  1. Invoke the findById() function of the TaskRepository class.
  2. If no task was found from the database, throw a NotFoundException.
  3. Create a new TaskDTO object by using the information found from the database. Get the person information of the assignee, closer, creator, and modifier by invoking the findPersonInformationByUserId() function of the PersonFinder class.
  4. Return the created TaskDTO object.

The source code of the TaskCrudService class looks as follows:

 1import org.springframework.beans.factory.annotation.Autowired
 2import org.springframework.stereotype.Service
 3import org.springframework.transaction.annotation.Transactional
 4
 5@Service
 6open class TaskCrudService(
 7        @Autowired private val personFinder: PersonFinder,
 8        @Autowired private val repository: TaskRepository
 9) {
10
11    @Transactional(readOnly = true)
12    open fun findById(id: Long): TaskDTO {
13        val found = repository.findById(id)
14                ?: throw NotFoundException("No task was found with id: #${id}")
15        return TaskDTO(
16                assignee = findPersonInformation(found.assignee?.id),
17                closer = findPersonInformation(found.closer?.id),
18                creator = personFinder
19                        .findPersonInformationByUserId(found.creator.id),
20                description = found.description,
21                id = found.id,
22                modifier = personFinder
23                        .findPersonInformationByUserId(found.modifier.id),
24                resolution = found.resolution,
25                status = found.status,
26                tags = transformTagsToDTOs(found.tags),
27                title = found.title
28        )
29    }
30
31    private fun findPersonInformation(userId: Long?): PersonDTO? {
32        return if (userId == null) {
33            return null
34        }
35        else {
36            personFinder.findPersonInformationByUserId(userId)
37        }
38    }
39
40    private fun transformTagsToDTOs(models: List<TaskTag>): List<TaskTagDTO> {
41        return models.map { TaskTagDTO(id = it.id, name = it.name) }
42    }
43}

The private data model of our REST API looks as follows:

 1data class Assignee(val id: Long)
 2
 3data class Closer(val id: Long)
 4
 5data class Creator(val id: Long)
 6
 7data class Modifier(val id: Long)
 8
 9enum class TaskResolution {
10    DUPLICATE,
11    DONE,
12    WONT_DO
13}
14
15enum class TaskStatus {
16    CLOSED,
17    IN_PROGRESS,
18    OPEN
19}
20
21data class TaskTag(val id: Long, val name: String)
22
23data class Task(val assignee: Assignee?,
24                val closer: Closer?,
25                val creator: Creator,
26                val description: String,
27                val id: Long,
28                val modifier: Modifier,
29                val resolution: TaskResolution?,
30                val status: TaskStatus,
31                val tags: List<TaskTag>,
32                val title: String)

We are now familiar with the system under test. Next, we will write a new request builder function.

Writing a New Request Builder Function

Before we can write unit tests for our REST API, we have to add a new function to our request builder class. We can write our request builder function by following these steps:

  1. Add a new function to our request builder class. This function takes a Long object as a function parameter and returns a ResultActions object.
  2. Send a GET request to the path: ‘/api/task/{id}’ by using the perform() function of the MockMvc class. Remember to return the ResultActions object that’s returned by the perform() function.

After we have added a new request builder function to our request builder class, its source code looks as follows:

 1import org.springframework.test.web.servlet.MockMvc
 2import org.springframework.test.web.servlet.ResultActions
 3import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
 4
 5class TaskCrudRequestBuilder(private val mockMvc: MockMvc) {
 6
 7    fun findById(id: Long): ResultActions {
 8        return mockMvc.perform(get("/api/task/{id}", id))
 9    }
10}

Let’s move on and find out how we can write unit tests for our REST API.

Writing Unit Tests

We can write unit tests for the findById() function of the TaskCrudController class by following these steps:

First, we have to add the required constants to our test class. After we have added these constants to our test class, its source code looks as follows:

 1import io.mockk.mockk
 2import org.junit.jupiter.api.BeforeEach
 3import org.junit.jupiter.api.DisplayName
 4import org.junit.jupiter.api.Tag
 5import org.springframework.test.web.servlet.setup.MockMvcBuilders
 6
 7@Tag("unitTest")
 8@DisplayName("Perform CRUD operations for tasks")
 9class TaskCrudControllerTest {
10
11    companion object {
12        private const val ASSIGNEE_ID = 99L
13        private const val ASSIGNEE_NAME = "Assignee"
14        private const val CLOSER_ID = 55L
15        private const val CLOSER_NAME = "Closer"
16        private const val CREATOR_ID = 44L
17        private const val CREATOR_NAME = "Creator"
18        private const val DESCRIPTION = "This an example task"
19        private const val MODIFIER_ID = 33L
20        private const val MODIFIER_NAME = "Modifier"
21        private val RESOLUTION_DONE = TaskResolution.DONE
22        private val STATUS_CLOSED = TaskStatus.CLOSED
23        private const val TAG_ID = 1L
24        private const val TAG_NAME = "tag"
25        private const val TASK_ID = 1L
26        private const val TITLE = "A task"
27    }
28
29    private lateinit var personFinder: PersonFinder
30    private lateinit var repository: TaskRepository
31    private lateinit var requestBuilder: TaskCrudRequestBuilder
32
33    @BeforeEach
34    fun configureSystemUnderTest() {
35        personFinder = mockk()
36        repository = mockk()
37        val service = TaskCrudService(personFinder, repository)
38
39        val mockMvc = MockMvcBuilders
40                .standaloneSetup(TaskCrudController(service))
41                .setControllerAdvice(TaskErrorHandler())
42                .setLocaleResolver(WebTestConfig.fixedLocaleResolver())
43                .setMessageConverters(
44                        WebTestConfig.objectMapperHttpMessageConverter()
45                )
46                .build()
47        requestBuilder = TaskCrudRequestBuilder(mockMvc)
48    }
49}

Second, we have to create the inner classes which contain our unit tests. We can create these inner classes by following these steps:

  1. Create the inner class which contains all unit tests of the findById() function.
  2. Create the inner class that contains the unit tests which ensure that our REST API is working as expected when the requested task isn’t found.
  3. Create the inner class that contains the unit tests which ensure that our REST API is working as expected when the requested task is found.

After we have created the required inner classes, the source code of our test class looks as follows:

 1import io.mockk.mockk
 2import org.junit.jupiter.api.BeforeEach
 3import org.junit.jupiter.api.DisplayName
 4import org.junit.jupiter.api.Nested
 5import org.junit.jupiter.api.Tag
 6import org.springframework.test.web.servlet.setup.MockMvcBuilders
 7
 8@Tag("unitTest")
 9@DisplayName("Perform CRUD operations for tasks")
10private class TaskCrudControllerTest {
11
12    companion object {
13        private const val ASSIGNEE_ID = 99L
14        private const val ASSIGNEE_NAME = "Assignee"
15        private const val CLOSER_ID = 55L
16        private const val CLOSER_NAME = "Closer"
17        private const val CREATOR_ID = 44L
18        private const val CREATOR_NAME = "Creator"
19        private const val DESCRIPTION = "This an example task"
20        private const val MODIFIER_ID = 33L
21        private const val MODIFIER_NAME = "Modifier"
22        private val RESOLUTION_DONE = TaskResolution.DONE
23        private val STATUS_CLOSED = TaskStatus.CLOSED
24        private const val TAG_ID = 1L
25        private const val TAG_NAME = "tag"
26        private const val TASK_ID = 1L
27        private const val TITLE = "A task"
28    }
29
30    private lateinit var personFinder: PersonFinder
31    private lateinit var repository: TaskRepository
32    private lateinit var requestBuilder: TaskCrudRequestBuilder
33
34    @BeforeEach
35    fun configureSystemUnderTest() {
36        personFinder = mockk()
37        repository = mockk()
38        val service = TaskCrudService(personFinder, repository)
39
40        val mockMvc = MockMvcBuilders
41                .standaloneSetup(TaskCrudController(service))
42                .setControllerAdvice(TaskErrorHandler())
43                .setLocaleResolver(WebTestConfig.fixedLocaleResolver())
44                .setMessageConverters(
45                        WebTestConfig.objectMapperHttpMessageConverter()
46                )
47                .build()
48        requestBuilder = TaskCrudRequestBuilder(mockMvc)
49    }
50
51    @Nested
52    @DisplayName("Find a task by using its id as search criteria")
53    inner class FindById {
54
55        @Nested
56        @DisplayName("When the requested task is not found")
57        inner class WhenRequestedTaskIsNotFound {
58
59        }
60
61        @Nested
62        @DisplayName("When the request task is found")
63        inner class WhenRequestedTaskIsFound {
64
65        }
66    }
67}

Third, we have to write the unit tests which ensure that our REST API is working as expected when the requested task isn’t found. We can write these tests by following these steps:

  1. Write a setup function that’s run before a test function is run. This function ensures that the findById() function of the TaskRepository class returns null when it’s invoked by using the function parameter: 1L.
  2. Ensure that our REST API returns the HTTP status code 404.
  3. Verify that our REST API returns an empty HTTP response.

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

 1import io.mockk.every
 2import io.mockk.mockk
 3import org.junit.jupiter.api.*
 4import org.springframework.http.MediaType
 5import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
 6import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
 7import org.springframework.test.web.servlet.setup.MockMvcBuilders
 8
 9@Tag("unitTest")
10@DisplayName("Perform CRUD operations for tasks")
11class TaskCrudControllerTest {
12
13    companion object {
14        private const val ASSIGNEE_ID = 99L
15        private const val ASSIGNEE_NAME = "Assignee"
16        private const val CLOSER_ID = 55L
17        private const val CLOSER_NAME = "Closer"
18        private const val CREATOR_ID = 44L
19        private const val CREATOR_NAME = "Creator"
20        private const val DESCRIPTION = "This an example task"
21        private const val MODIFIER_ID = 33L
22        private const val MODIFIER_NAME = "Modifier"
23        private val RESOLUTION_DONE = TaskResolution.DONE
24        private val STATUS_CLOSED = TaskStatus.CLOSED
25        private const val TAG_ID = 1L
26        private const val TAG_NAME = "tag"
27        private const val TASK_ID = 1L
28        private const val TITLE = "A task"
29    }
30
31    private lateinit var personFinder: PersonFinder
32    private lateinit var repository: TaskRepository
33    private lateinit var requestBuilder: TaskCrudRequestBuilder
34
35    @BeforeEach
36    fun configureSystemUnderTest() {
37        personFinder = mockk()
38        repository = mockk()
39        val service = TaskCrudService(personFinder, repository)
40
41        val mockMvc = MockMvcBuilders
42                .standaloneSetup(TaskCrudController(service))
43                .setControllerAdvice(TaskErrorHandler())
44                .setLocaleResolver(WebTestConfig.fixedLocaleResolver())
45                .setMessageConverters(
46                        WebTestConfig.objectMapperHttpMessageConverter()
47                )
48                .build()
49        requestBuilder = TaskCrudRequestBuilder(mockMvc)
50    }
51
52    @Nested
53    @DisplayName("Find a task by using its id as search criteria")
54    inner class FindById {
55
56        @Nested
57        @DisplayName("When the requested task is not found")
58        inner class WhenRequestedTaskIsNotFound {
59
60            @BeforeEach
61            fun repositoryReturnsNull() {
62                every { repository.findById(TASK_ID) } returns null
63            }
64
65            @Test
66            @DisplayName("Should return the HTTP status code not found")
67            fun shouldReturnHttpStatusCodeNotFound() {
68                requestBuilder.findById(TASK_ID)
69                        .andExpect(status().isNotFound)
70            }
71
72            @Test
73            @DisplayName("Should return an empty HTTP response")
74            fun shouldReturnEmptyHttpResponse() {
75                requestBuilder.findById(TASK_ID)
76                        .andExpect(content().string(""))
77            }
78        }
79
80        @Nested
81        @DisplayName("When the request task is found")
82        inner class WhenRequestedTaskIsFound {
83
84        }
85    }
86}

Fourth, we have to write the unit tests which ensure that our REST API is working as expected when the requested task is found. We can write these tests by following these steps:

  1. Write a private function which ensures that the findById() function of the TaskRepository class returns the found task when it’s invoked by using the function parameter: 1L.
  2. Write a private function which ensures that the findPersonInformationByUserId() function of the PersonFinder class returns the person information of the assignee, closer, creator, and modifier of the found task.
  3. Write a setup function that’s run before a test function is run. The implementation of this function simply invokes our private utility functions.
  4. Ensure that our REST API returns the HTTP status code 200.
  5. Verify that our REST API returns the found information as Json.
  6. Ensure that our REST API returns the information of the correct task.
  7. Verify that our REST API returns the person information of the assignee.
  8. Ensure that our REST API returns the information of the person who closed the task.
  9. Verify that our REST API returns the information of the person who created the task.
  10. Ensure that our REST API returns the information of the person who modified the task.
  11. Verify that our REST API returns the description and title of the found task.
  12. Ensure that our REST API returns a task that was closed because it was done.
  13. Verify that our REST API returns a task that has one tag.
  14. Ensure that our REST API returns the information of the found tag.

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

  1import io.mockk.every
  2import io.mockk.mockk
  3import org.hamcrest.Matchers.equalTo
  4import org.hamcrest.Matchers.hasSize
  5import org.junit.jupiter.api.*
  6import org.junit.jupiter.api.Tag
  7import org.springframework.http.MediaType
  8import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
  9import org.springframework.test.web.servlet.setup.MockMvcBuilders
 10
 11@Tag("unitTest")
 12@DisplayName("Perform CRUD operations for tasks")
 13class TaskCrudControllerTest {
 14
 15    companion object {
 16        private const val ASSIGNEE_ID = 99L
 17        private const val ASSIGNEE_NAME = "Assignee"
 18        private const val CLOSER_ID = 55L
 19        private const val CLOSER_NAME = "Closer"
 20        private const val CREATOR_ID = 44L
 21        private const val CREATOR_NAME = "Creator"
 22        private const val DESCRIPTION = "This an example task"
 23        private const val MODIFIER_ID = 33L
 24        private const val MODIFIER_NAME = "Modifier"
 25        private val RESOLUTION_DONE = TaskResolution.DONE
 26        private val STATUS_CLOSED = TaskStatus.CLOSED
 27        private const val TAG_ID = 1L
 28        private const val TAG_NAME = "tag"
 29        private const val TASK_ID = 1L
 30        private const val TITLE = "A task"
 31    }
 32
 33    private lateinit var personFinder: PersonFinder
 34    private lateinit var repository: TaskRepository
 35    private lateinit var requestBuilder: TaskCrudRequestBuilder
 36
 37    @BeforeEach
 38    fun configureSystemUnderTest() {
 39        personFinder = mockk()
 40        repository = mockk()
 41        val service = TaskCrudService(personFinder, repository)
 42
 43        val mockMvc = MockMvcBuilders
 44                .standaloneSetup(TaskCrudController(service))
 45                .setControllerAdvice(TaskErrorHandler())
 46                .setLocaleResolver(WebTestConfig.fixedLocaleResolver())
 47                .setMessageConverters(
 48                        WebTestConfig.objectMapperHttpMessageConverter()
 49                )
 50                .build()
 51        requestBuilder = TaskCrudRequestBuilder(mockMvc)
 52    }
 53
 54    @Nested
 55    @DisplayName("Find a task by using its id as search criteria")
 56    inner class FindById {
 57
 58        @Nested
 59        @DisplayName("When the requested task is not found")
 60        inner class WhenRequestedTaskIsNotFound {
 61
 62            @BeforeEach
 63            fun repositoryReturnsNull() {
 64                every { repository.findById(TASK_ID) } returns null
 65            }
 66
 67            @Test
 68            @DisplayName("Should return the HTTP status code not found")
 69            fun shouldReturnHttpStatusCodeNotFound() {
 70                requestBuilder.findById(TASK_ID)
 71                        .andExpect(status().isNotFound)
 72            }
 73
 74            @Test
 75            @DisplayName("Should return an empty HTTP response")
 76            fun shouldReturnEmptyHttpResponse() {
 77                requestBuilder.findById(TASK_ID)
 78                        .andExpect(content().string(""))
 79            }
 80        }
 81
 82        @Nested
 83        @DisplayName("When the request task is found")
 84        inner class WhenRequestedTaskIsFound {
 85
 86            @BeforeEach
 87            fun configureTestCases() {
 88                repositoryReturnsFoundTask()
 89                personFinderReturnsRelatedPersonData()
 90            }
 91
 92            private fun repositoryReturnsFoundTask() {
 93                every { repository.findById(TASK_ID) } returns Task(
 94                        assignee = Assignee(ASSIGNEE_ID),
 95                        closer = Closer(CLOSER_ID),
 96                        creator = Creator(CREATOR_ID),
 97                        description = DESCRIPTION,
 98                        id = TASK_ID,
 99                        modifier = Modifier(MODIFIER_ID),
100                        resolution = RESOLUTION_DONE,
101                        status = STATUS_CLOSED,
102                        tags = listOf(TaskTag(id = TAG_ID, name = TAG_NAME)),
103                        title = TITLE
104                )
105            }
106
107            private fun personFinderReturnsRelatedPersonData() {
108                every {
109                    personFinder.findPersonInformationByUserId(ASSIGNEE_ID)
110                } returns PersonDTO(name = ASSIGNEE_NAME, userId = ASSIGNEE_ID)
111                every {
112                    personFinder.findPersonInformationByUserId(CLOSER_ID)
113                } returns PersonDTO(name = CLOSER_NAME, userId = CLOSER_ID)
114                every {
115                    personFinder.findPersonInformationByUserId(CREATOR_ID)
116                } returns PersonDTO(name = CREATOR_NAME, userId = CREATOR_ID)
117                every {
118                    personFinder.findPersonInformationByUserId(MODIFIER_ID)
119                } returns PersonDTO(name = MODIFIER_NAME, userId = MODIFIER_ID)
120            }
121
122            @Test
123            @DisplayName("Should return the HTTP status code OK (200)")
124            fun shouldReturnHttpStatusCodeOk() {
125                requestBuilder.findById(TASK_ID)
126                        .andExpect(status().isOk)
127            }
128
129            @Test
130            @DisplayName("Should return the information of the found task as Json")
131            fun shouldReturnInformationOfFoundTaskAsJson() {
132                requestBuilder.findById(TASK_ID)
133                        .andExpect(content().contentType(MediaType.APPLICATION_JSON))
134            }
135
136            @Test
137            @DisplayName("Should return the information of the found task")
138            fun shouldReturnInformationOfFoundTask() {
139                requestBuilder.findById(TASK_ID)
140                        .andExpect(jsonPath("$.id", equalTo(TASK_ID.toInt())))
141            }
142
143            @Test
144            @DisplayName("Should return the information of the assignee")
145            fun shouldReturnCorrectAssignee() {
146                requestBuilder.findById(TASK_ID)
147                        .andExpect(jsonPath("$.assignee.name", equalTo(ASSIGNEE_NAME)))
148                        .andExpect(jsonPath("$.assignee.userId", equalTo(ASSIGNEE_ID.toInt())))
149            }
150
151            @Test
152            @DisplayName("Should return the information of the person who closed the task")
153            fun shouldReturnCorrectCloser() {
154                requestBuilder.findById(TASK_ID)
155                        .andExpect(jsonPath("$.closer.name", equalTo(CLOSER_NAME)))
156                        .andExpect(jsonPath("$.closer.userId", equalTo(CLOSER_ID.toInt())))
157            }
158
159            @Test
160            @DisplayName("Should return the the information of the person who created the task")
161            fun shouldReturnCorrectCreator() {
162                requestBuilder.findById(TASK_ID)
163                        .andExpect(jsonPath("$.creator.name", equalTo(CREATOR_NAME)))
164                        .andExpect(jsonPath("$.creator.userId", equalTo(CREATOR_ID.toInt())))
165            }
166
167            @Test
168            @DisplayName("Should return the the information of the person who modified the task")
169            fun shouldReturnCorrectModifier() {
170                requestBuilder.findById(TASK_ID)
171                        .andExpect(jsonPath("$.modifier.name", equalTo(MODIFIER_NAME)))
172                        .andExpect(jsonPath("$.modifier.userId", equalTo(MODIFIER_ID.toInt())))
173            }
174
175            @Test
176            @DisplayName("Should return the correct description and title")
177            fun shouldReturnCorrectDescriptionAndTitle() {
178                requestBuilder.findById(TASK_ID)
179                        .andExpect(jsonPath("$.description", equalTo(DESCRIPTION)))
180                        .andExpect(jsonPath("$.title", equalTo(TITLE)))
181            }
182
183            @Test
184            @DisplayName("Should return a task that was closed because it was done")
185            fun shouldReturnTaskThatIsDone() {
186                requestBuilder.findById(TASK_ID)
187                        .andExpect(jsonPath("$.resolution", equalTo(RESOLUTION_DONE.name)))
188                        .andExpect(jsonPath("$.status", equalTo(STATUS_CLOSED.name)))
189            }
190
191            @Test
192            @DisplayName("Should return a task that has one tag")
193            fun shouldReturnTaskThatHasOneTag() {
194                requestBuilder.findById(TASK_ID)
195                        .andExpect(jsonPath("$.tags", hasSize<Any>(1)))
196            }
197
198            @Test
199            @DisplayName("Should return the information of the found tag")
200            fun shouldReturnInformationOfFoundTag() {
201                requestBuilder.findById(TASK_ID)
202                        .andExpect(jsonPath("$.tags[0].id", equalTo(TAG_ID.toInt())))
203                        .andExpect(jsonPath("$.tags[0].name", equalTo(TAG_NAME)))
204            }
205        }
206    }
207}
The downside of this approach is that our tests will pass if we add new attributes to the returned Json document. I think that this is acceptable because the approach described in this lesson helps us to write tests which are easy to read. If you disagree with me, you can specify the returned Json document by using the json() method of the ContentResultMatchers class.

We can now write unit tests for a REST API that returns data as Json. Let’s summarize what we learned from this lesson.

Summary

This lesson has taught us three things:

  • We can separate different scenarios by using inner classes.
  • If we write assertions for the returned Json document by using JsonPath expressions, our tests will pass if we add new attributes to the returned Json document.
  • We can specify the returned json document by using the json() method of the ContentResultMatchers class.

Get the source code from Github