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

After we have finished this lesson, we understand how we can an write integration tests for a REST API that reads data from the database.

Get the source code from Github

Introduction to the System Under Test

During this lesson we will write integration tests for a controller function which processes GET requests send to the path: ‘/api/task/{id}’. This function gets the information of the requested task from the database, and returns the information of the found task as JSON.

If no task is found from the database, this function returns the HTTP status code 404. Also, if an anonymous user tries to invoke this controller function, our REST API returns the HTTP status code 401.

The source code of our controller 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}

If the requested task is found from the database, our controller function returns the following JSON document back to the client:

 1{
 2  "assignee": {
 3    "name": "John Doe",
 4    "userId": 1
 5  },
 6  "closer": {
 7    "name": "John Doe",
 8    "userId": 1
 9  },
10  "creator": {
11    "name": "John Doe",
12    "userId": 1
13  },
14  "description": "This example contains integration tests",
15  "id": 1,
16  "modifier": {
17    "name": "John Doe",
18    "userId": 1
19  },
20  "resolution": "DONE",
21  "status": "CLOSED",
22  "tags": [
23    {
24      "id": 1,
25      "name": "example"
26    }
27  ],
28  "title": "Write example application"
29}

Next, we will make one small change to the configuration of the system under test.

Changing the Configuration of the System Under Test

If the system under test uses Spring Security, we can configure the authenticated user by using the @WithUserDetails annotation. However, if we use this annotation without changing the configuration which we created in the previous lessons, we notice that the Spring TestContext framework cannot create the security context because the information of the authenticated user isn’t found from the database:

1java.lang.IllegalStateException: Unable to create SecurityContext using
2@org.springframework.security.test.context.support.WithUserDetails(
3setupBefore=TEST_METHOD, userDetailsServiceBeanName=, value=john.doe@gmail.com)
4
5Caused by: org.springframework.security.core.userdetails.UsernameNotFoundException:
6No user found with username: john.doe@gmail.com

When we configure the authenticated user by using the @WithUserDetails annotation, the WithSecurityContextTestExecutionListener creates a new SecurityContext which contains the information of the authenticated user. The problem is that the SecurityContext is created before our test data is inserted into the database by the DbUnitTestExecutionListener class.

We can fix this problem by following these steps:

First, we have to create a new TestExecutionListener which extends the DbUnitTestExecutionListener class. Our new TestExecutionListener ensures that our test data is inserted into the database before the SecurityContext is created by the WithSecurityContextTestExecutionListener class.

The source code of our new TestExecutionListener class looks as follows:

1import com.github.springtestdbunit.DbUnitTestExecutionListener
2
3class OrderedDbUnitTestExecutionListener: DbUnitTestExecutionListener() {
4
5    override fun getOrder(): Int {
6        return 9999
7    }
8}

Second, we have to ensure that our integration tests use our new TestExecutionListener. After we have done this, the source code of the @DatabaseIntegrationTest annotation looks as follows:

 1import com.github.springtestdbunit.DbUnitTestExecutionListener
 2import org.junit.jupiter.api.Tag
 3import org.junit.jupiter.api.extension.ExtendWith
 4import org.springframework.boot.test.context.SpringBootTest
 5import org.springframework.test.context.ActiveProfiles
 6import org.springframework.test.context.ContextConfiguration
 7import org.springframework.test.context.TestExecutionListeners
 8import org.springframework.test.context.junit.jupiter.SpringExtension
 9
10@Target(AnnotationTarget.CLASS)
11@Retention(AnnotationRetention.RUNTIME)
12@MustBeDocumented
13@SpringBootTest
14@TestExecutionListeners(
15        listeners = [OrderedDbUnitTestExecutionListener::class],
16        mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS
17)
18@ActiveProfiles("integrationTest")
19@Tag("integrationTest")
20annotation class DatabaseIntegrationTest

Let’s move on and find out how we can write integration tests for our controller function.

Writing Integration Tests for Our Controller Function

During this lesson we will verify that the system under test is working as expected when the authenticated user is a normal user. We can do this by following these steps:

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

  1. Create the inner classes which contain the test functions which ensure that the system under test is working as expected when the requested task isn’t found and the authenticated user is a normal user.
  2. Create the inner classes which contain the test functions which ensure that the system under test is working as expected when the requested task is found and the authenticated user is a normal user.

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

 1import com.github.springtestdbunit.annotation.DatabaseSetup
 2import com.github.springtestdbunit.annotation.DbUnitConfiguration
 3import com.github.springtestdbunit.dataset.ReplacementDataSetLoader
 4import org.junit.jupiter.api.DisplayName
 5import org.junit.jupiter.api.Nested
 6import org.springframework.beans.factory.annotation.Autowired
 7import org.springframework.security.test.context.support.WithUserDetails
 8import org.springframework.test.web.servlet.MockMvc
 9
10@WebIntegrationTest
11@DisplayName("Find task by id")
12class FindTaskByIdApiTest(@Autowired private val mockMvc: MockMvc) {
13
14    private val requestBuilder = TaskCrudRequestBuilder(mockMvc)
15
16    @Nested
17    @DisplayName("When the requested task isn't found")
18    inner class WhenRequestedTaskIsNotFound {
19
20        @Nested
21        @WebIntegrationTest
22        @DatabaseSetup(value = [
23            "/com/testwithspring/master/users.xml",
24            "/com/testwithspring/master/tasks.xml"
25        ])
26        @DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
27        @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
28        @DisplayName("When the user is John Doe")
29        inner class WhenUserIsJohnDoe {
30
31        }
32    }
33
34    @Nested
35    @DisplayName("When the requested task is found")
36    inner class WhenRequestedTaskIsFound {
37
38        @Nested
39        @WebIntegrationTest
40        @DatabaseSetup(value = [
41            "/com/testwithspring/master/users.xml",
42            "/com/testwithspring/master/tasks.xml"
43        ])
44        @DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
45        @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
46        @DisplayName("When the user is John Doe")
47        inner class WhenUserIsJohnDoe {
48
49        }
50    }
51}

The DbUnit data set (users.xml) which inserts the user data into our database looks as follows:

 1<dataset>
 2    <user_accounts id="1"
 3                   email_address="john.doe@gmail.com"
 4                   is_enabled="true"
 5                   name="John Doe"
 6                   password="user"
 7                   role="ROLE_USER"/>
 8    <user_accounts id="2"
 9                   email_address="anne.admin@gmail.com"
10                   is_enabled="true"
11                   name="Anne Admin"
12                   password="admin"
13                   role="ROLE_ADMIN"/>
14</dataset>

The DbUnit data set (tasks.xml) which inserts tasks and tags into our database looks as follows:

 1<dataset>
 2    <tasks id="1"
 3           assignee_id="1"
 4           closer_id="1"
 5           creator_id="1"
 6           description="This example contains integration tests"
 7           modifier_id="1"
 8           resolution="DONE"
 9           status="CLOSED"
10           title="Write example application"/>
11    <tasks id="2"
12           assignee_id="[null]"
13           closer_id="[null]"
14           creator_id="1"
15           description="This lesson talks about integration testing"
16           modifier_id="1"
17           resolution="[null]"
18           status="OPEN"
19           title="Write lesson"/>
20
21    <tags id="1"
22          name="example"/>
23    <tags id="2"
24          name="lesson"/>
25
26    <tasks_tags task_id="1" tag_id="1"/>
27    <tasks_tags task_id="2" tag_id="2"/>
28</dataset>

The Users class is a constant class which contains the test data found from the user_accounts table. The relevant part of its source code looks as follows:

 1class Users {
 2
 3    class JohnDoe {
 4
 5        companion object {
 6            const val ID = 1L
 7            const val EMAIL_ADDRESS = "john.doe@gmail.com"
 8            const val NAME = "John Doe"
 9        }
10    }
11}
The example application of this lesson contains integration tests which ensure that the system under test is working as expected when the user is anonymous or when the authenticated user is an administrator. If you want to take a look at these test functions, you should get the example application from Github .

Second, we have to write the test functions which ensure that the system under test is working as expected when the requested task isn’t found. We can write these test functions by following these steps:

  1. Ensure that our REST API returns the HTTP status code 404.
  2. Verify that our REST API returns an empty HTTP response.

After we have written these test functions, the relevant part of our test class looks as follows:

 1import com.github.springtestdbunit.annotation.DatabaseSetup
 2import com.github.springtestdbunit.annotation.DbUnitConfiguration
 3import com.github.springtestdbunit.dataset.ReplacementDataSetLoader
 4import org.junit.jupiter.api.DisplayName
 5import org.junit.jupiter.api.Nested
 6import org.junit.jupiter.api.Test
 7import org.springframework.beans.factory.annotation.Autowired
 8import org.springframework.security.test.context.support.WithUserDetails
 9import org.springframework.test.web.servlet.MockMvc
10import org.springframework.test.web.servlet.result.MockMvcResultMatchers.content
11import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
12
13@WebIntegrationTest
14@DisplayName("Find task by id")
15class FindTaskByIdApiTest(@Autowired private val mockMvc: MockMvc) {
16
17    private val requestBuilder = TaskCrudRequestBuilder(mockMvc)
18
19    @Nested
20    @DisplayName("When the requested task isn't found")
21    inner class WhenRequestedTaskIsNotFound {
22
23        @Nested
24        @WebIntegrationTest
25        @DatabaseSetup(value = [
26            "/com/testwithspring/master/users.xml",
27            "/com/testwithspring/master/tasks.xml"
28        ])
29        @DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
30        @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
31        @DisplayName("When the user is John Doe")
32        inner class WhenUserIsJohnDoe {
33
34            @Test
35            @DisplayName("Should return the HTTP status code not found")
36            fun shouldReturnHttpStatusCodeNotFound() {
37                requestBuilder.findById(Tasks.ID_NOT_FOUND)
38                        .andExpect(status().isNotFound)
39            }
40
41            @Test
42            @DisplayName("Should return an empty HTTP response")
43            fun shouldReturnEmptyHttpResponse() {
44                requestBuilder.findById(Tasks.ID_NOT_FOUND)
45                        .andExpect(content().string(""))
46            }
47        }
48    }
49}

The Tasks class is a constant class which contains the test data found from the tasks table. The relevant part of its source code looks as follows:

 1class Tasks {
 2
 3    companion object {
 4
 5        const val ID_NOT_FOUND = 99L
 6    }
 7
 8    class WriteExampleApplication {
 9
10        companion object {
11
12            const val ID = 1L
13            const val ASSIGNEE_ID = Users.JohnDoe.ID
14            const val ASSIGNEE_NAME = Users.JohnDoe.NAME
15            const val CLOSER_ID = Users.JohnDoe.ID
16            const val CLOSER_NAME = Users.JohnDoe.NAME
17            const val CREATOR_ID = Users.JohnDoe.ID
18            const val CREATOR_NAME = Users.JohnDoe.NAME
19            const val DESCRIPTION = "This example contains integration tests"
20            const val MODIFIER_ID = Users.JohnDoe.ID
21            const val MODIFIER_NAME = Users.JohnDoe.NAME
22            const val TITLE = "Write example application"
23            val RESOLUTION = TaskResolution.DONE
24            val STATUS = TaskStatus.CLOSED
25        }
26
27        class Tags {
28
29            class Example {
30
31                companion object {
32
33                    const val ID = 1L
34                    const val NAME = "example"
35                }
36            }
37        }
38    }
39}

Third, we have to write the test functions which ensure that the system under test is working as expected when the requested task is found. We can write these test functions by following these steps:

  1. Ensure that our REST API returns the HTTP status code 200.
  2. Verify that our REST API returns the found information as JSON.
  3. Ensure that our REST API returns the information of the found task.
  4. Verify that our REST API returns the person information of the assignee.
  5. Ensure that our REST API returns the information of the person who closed the task.
  6. Verify that our REST API returns the information of the person who created the task.
  7. Ensure that our REST API returns the information of the person who modified the task.
  8. Verify that our REST API returns the description and title of the found task.
  9. Ensure that our REST API returns a task that was closed because it was done.
  10. Verify that our REST API returns a task that has one tag.
  11. Ensure that our REST API returns the information of the found tag.

After we have written these test functions, the relevant part of our test class looks as follows:

  1import com.github.springtestdbunit.annotation.DatabaseSetup
  2import com.github.springtestdbunit.annotation.DbUnitConfiguration
  3import com.github.springtestdbunit.dataset.ReplacementDataSetLoader
  4import org.hamcrest.Matchers.equalTo
  5import org.hamcrest.Matchers.hasSize
  6import org.junit.jupiter.api.DisplayName
  7import org.junit.jupiter.api.Nested
  8import org.junit.jupiter.api.Test
  9import org.springframework.beans.factory.annotation.Autowired
 10import org.springframework.http.MediaType
 11import org.springframework.security.test.context.support.WithUserDetails
 12import org.springframework.test.web.servlet.MockMvc
 13import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
 14
 15@WebIntegrationTest
 16@DisplayName("Find task by id")
 17class FindTaskByIdApiTest(@Autowired private val mockMvc: MockMvc) {
 18
 19    private val requestBuilder = TaskCrudRequestBuilder(mockMvc)
 20
 21    @Nested
 22    @DisplayName("When the requested task is found")
 23    inner class WhenRequestedTaskIsFound {
 24
 25        @Nested
 26        @WebIntegrationTest
 27        @DatabaseSetup(value = [
 28            "/com/testwithspring/master/users.xml",
 29            "/com/testwithspring/master/tasks.xml"
 30        ])
 31        @DbUnitConfiguration(dataSetLoader = ReplacementDataSetLoader::class)
 32        @WithUserDetails(Users.JohnDoe.EMAIL_ADDRESS)
 33        @DisplayName("When the user is John Doe")
 34        inner class WhenUserIsJohnDoe {
 35
 36            @Test
 37            @DisplayName("Should return the HTTP status code ok")
 38            fun shouldReturnHttpStatusCodeOk() {
 39                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
 40                        .andExpect(status().isOk)
 41            }
 42
 43            @Test
 44            @DisplayName("Should return the information of the found task as Json")
 45            fun shouldReturnInformationOfFoundTaskAsJson() {
 46                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
 47                        .andExpect(
 48                                content().contentType(MediaType.APPLICATION_JSON)
 49                        )
 50            }
 51
 52            @Test
 53            @DisplayName("Should return the information of the found task")
 54            fun shouldReturnInformationOfFoundTask() {
 55                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
 56                        .andExpect(jsonPath(
 57                                "$.id",
 58                                equalTo(Tasks.WriteExampleApplication.ID.toInt())
 59                        ))
 60            }
 61
 62            @Test
 63            @DisplayName("Should return the information of the assignee")
 64            fun shouldReturnInformationOfAssignee() {
 65                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
 66                        .andExpect(jsonPath(
 67                                "$.assignee.name",
 68                                equalTo(Tasks.WriteExampleApplication.ASSIGNEE_NAME)
 69                        ))
 70                        .andExpect(jsonPath(
 71                                "$.assignee.userId",
 72                                equalTo(Tasks.WriteExampleApplication.ASSIGNEE_ID.toInt())
 73                        ))
 74            }
 75
 76            @Test
 77            @DisplayName("Should return the information of the closer")
 78            fun shouldReturnInformationOfCloser() {
 79                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
 80                        .andExpect(jsonPath(
 81                                "$.closer.name",
 82                                equalTo(Tasks.WriteExampleApplication.CLOSER_NAME)
 83                        ))
 84                        .andExpect(jsonPath(
 85                                "$.closer.userId",
 86                                equalTo(Tasks.WriteExampleApplication.CLOSER_ID.toInt())
 87                        ))
 88            }
 89
 90            @Test
 91            @DisplayName("Should return the information of the creator")
 92            fun shouldReturnInformationOfCreator() {
 93                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
 94                        .andExpect(jsonPath(
 95                                "$.creator.name",
 96                                equalTo(Tasks.WriteExampleApplication.CREATOR_NAME)
 97                        ))
 98                        .andExpect(jsonPath(
 99                                "$.creator.userId",
100                                equalTo(Tasks.WriteExampleApplication.CREATOR_ID.toInt())
101                        ))
102            }
103
104            @Test
105            @DisplayName("Should return the information of the modifier")
106            fun shouldReturnInformationOfModifier() {
107                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
108                        .andExpect(jsonPath(
109                                "$.modifier.name",
110                                equalTo(Tasks.WriteExampleApplication.MODIFIER_NAME)
111                        ))
112                        .andExpect(jsonPath(
113                                "$.modifier.userId",
114                                equalTo(Tasks.WriteExampleApplication.MODIFIER_ID.toInt())
115                        ))
116            }
117
118            @Test
119            @DisplayName("Should return the correct description and title")
120            fun shouldReturnCorrectDescriptionAndTitle() {
121                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
122                        .andExpect(jsonPath(
123                                "$.description",
124                                equalTo(Tasks.WriteExampleApplication.DESCRIPTION)
125                        ))
126                        .andExpect(jsonPath(
127                                "$.title",
128
129                                equalTo(Tasks.WriteExampleApplication.TITLE)
130                        ))
131            }
132
133            @Test
134            @DisplayName("Should return a task that was closed because it was done")
135            fun shouldReturnTaskThatIsDone() {
136                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
137                        .andExpect(jsonPath(
138                                "$.resolution",
139                                equalTo(Tasks.WriteExampleApplication.RESOLUTION.name)
140                        ))
141                        .andExpect(jsonPath(
142                                "$.status",
143                                equalTo(Tasks.WriteExampleApplication.STATUS.name)
144                        ))
145            }
146
147            @Test
148            @DisplayName("Should return a task that has one tag")
149            fun shouldReturnTaskThatHasOneTag() {
150                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
151                        .andExpect(jsonPath("$.tags", hasSize<Any>(1)))
152            }
153
154            @Test
155            @DisplayName("Should return the information of the found tag")
156            fun shouldReturnInformationOfFoundTag() {
157                requestBuilder.findById(Tasks.WriteExampleApplication.ID)
158                        .andExpect(jsonPath(
159                                "$.tags[0].id",
160                                equalTo(Tasks.WriteExampleApplication.Tags.Example.ID.toInt())
161                        ))
162                        .andExpect(jsonPath(
163                                "$.tags[0].name",
164                                equalTo(Tasks.WriteExampleApplication.Tags.Example.NAME)
165                        ))
166            }
167        }
168    }
169}

We can now write integration tests for a REST API that reads data from the database. Let’s summarize what we learned from this lesson.

Summary

This lesson has taught us two things:

  • If the system under test uses Spring Security and we want to use Spring Test DbUnit, we have to extend the DbUnitTestExecutionListener class and ensure that our test data is inserted into the database before the SecurityContext is created by the WithSecurityContextTestExecutionListener class.
  • If we want to write nested integration tests, we have to add all configuration annotations to an inner class if the inner class contains a test function.

Get the source code from Github