Integration tests — an approach for the REST API
The kind of integration tests this story is about are the ones to be executed both on the dev machine, and in the CI/CD pipeline. The aim is to maximise confidence before the API takes off.
The technologies applied here are: Kotlin, Spring Boot 2, Kotest, Mockito, RestAssured and TestContainers.
I will explore the goals which I believe to be necessary for effective integration tests:
- It is easy to understand what is under test
- A dedicated database is setup to serve sample data (for the projects that needs one)
- It is easy to create data driven tests
- It is easy to perform assertions on the response body and headers
Additionally, it is important that the integration tests looks as clear as possible.
The source code with a running sample is provided here.
Integration test is defined in Wikipedia as “the phase in software testing in which individual software modules are combined and tested as a group”. Here, for clarity, individual software modules are the ones within the service boundary (i.e. with Appointments service boundary).
Goal: Easy to understand what is under test
The first point of contextualisation is the class/file name. Regardless of the tool in use, it must be possible to know what is under test by looking at the file names itself. For the sample application, we gonna have:
- AppointmentGetIT*
- AppointmentPostIT*
- AppointmentPutIT
- AppointmentDeleteIT
The ones marked with * are the ones we will explore in this story.
It's also important to have clear function names for the different operations we gonna to perform on the same HTTP method. See the following snippet.
class AppointmentGetIT() : StringSpec() {
init {
"should get appointments by id" { // appointments/{id}
}
"should get appointments by date" { // appointments?date=x
}
}
}
The tool in use is also relevant for clarity. I have found Kotest a simple, but yet good solution for the job. It has different ways to express the scenarios and a good approach for data driven tests.
We have to configure Kotest to be able to use Spring annotations, in order to boot the embeeded server. The Kotest Spring extension is required here.
@SpringBootTest(classes = [Application::class],
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class AppointmentGetIT() : StringSpec() { ... }
Now that we have the basic file and content structure, let's provide data.
Goal: A dedicated database is setup to serve sample data
Before firing any requests to the GET endpoint, it would be great if we had some data. That can be done in different ways and, for micro-services, it can be a more enjoyable task. Some alternatives are:
- run a migration script before each test case
- auto-wiring a repository and create records before each test case
- use a mock repository to return some data
To call the POST endpoint to create data is an alternative as well. However, it's not a good one, for different reasons. First it may not have been implemented yet. Second, the POST request may fail due different reasons and, for the GET test perspective, it doesn't matter if POST is working or not.
Given the three enumerated alternatives, let’s try the first one and see if may give us a clear result.
Let's explore a tool called Flyway for database migrations. It's a tool to have control over the evolution of your database, and it helps testing, as we could perform a migration before each test case. The sample application is already configured with Flyway. The key points of interest of flyway's configuration in the project are the:
- application-local.yaml
flyway:
locations:
- classpath:db/migration/
- classpath:db/migration-test
Different from the production configuration, the local configuration has an extra location for the scripts
2. afterMigrate.sql
Flyway will auto execute a file called afterMigrate.sql if found. Ours is located at src/main/test/resources/db.migration-test
Now, by executing the AppointmentGetIT, which will be executed using the local spring profile (see ProjectConfig class), our database will be populated.
But wait, in which database the scripts will be executed? Well, it would be nice to have it locally and preferable the same database product that is used in production. To accomplish that, the sample application is equipped with TestContainers, a tool that provides a selection of ready to use database instances. It requires that you have docker installed and it will download and run the image during the test phase (only once). You can configure the tool in different ways. For the sample application, it is configured before running testcases, using the Kotest ProjectConfig class.
var pgContainer = PostgreSQLContainer<Nothing>("postgres:11.1")
object ProjectConfig : AbstractProjectConfig() {
override suspend fun beforeProject() {
super.beforeProject()
System.setProperty("spring.profiles.active", "local,test")
pgContainer.start()
}
override suspend fun afterProject() {
super.afterProject()
pgContainer.stop()
}
}
Additionally, we need to add the Spring configuration to setup the test datasource.
@org.springframework.context.annotation.Configuration
class SpringConfig {
@Bean
fun getDataSource(): DataSource? {
val dbPort = pgContainer.getMappedPort(5432)
val dataSourceBuilder = DataSourceBuilder.create()
dataSourceBuilder.driverClassName("org.postgresql.Driver")
dataSourceBuilder.url("jdbc:postgresql://localhost:$dbPort/test?stringtype=unspecified")
dataSourceBuilder.username("test")
dataSourceBuilder.password("test")
return dataSourceBuilder.build()
}
}
Note that a object pgContainer is used to get the current database port. Testcontainers will select a random unused port everytime it runs, therefore we need to obtain it while configuring the datasource. Let's enrich our ProjectConfig file with the TestContainers setup.
Now we are ready to write the test cases.
Goal: Easy to create data driven tests
Kotest does provide a friendly way to create data driven tests, from the developer point of view. In Kotlintest, you provide a table with data to be tested and the expected results as well (variables starting with 'exp' below).
...
"should get appointments by id" {
forall (
row( 1, -1, 200, "Robert Smith", true, "John Smith"),
row(99, 0, 404, null, null, null),
row( 1, 0, 304, null, null, null)
) { id, version, expStatus, expName, expType, expDocName -> }
}
...
In the code snippet above we are expecting a status code and some payload information to match. For the statuses 404 and 304, we don't have any expectations regarding the payload. The status is, solely, the relevant information.
Notice that, the data provided in the rows may easily becomes extensive. That would ruin the readability of the test class. However, we have a way out of it and we will see it further down, while testing POST.
Now that we have data, let's fire requests.
Goal: Easy to fire requests and perform assertions on response headers and body
Now that we have some data stored in the database, sample data for the request and the expectations, we can start performing the requests.
For that we are going to use a tool called REST-assured, and see how interesting its features are. Dependencies are already provided in the build.gradle file.
First, let's tell REST-assured about the details of our server.
@SpringBootTest(classes = [Application::class], webEnvironment = RANDOM_PORT)
class AppointmentGetIT(@LocalServerPort port: String) : StringSpec() {
init {
RestAssured.port = port.toInt()
RestAssured.baseURI = "http://localhost" "should get appointments by id" {
...
} "should get appointments by date" {
...
}
}
}
Notice the injection of the server port and the setup done in the init. Now, that we told REST-assured about the server details, let's perform the request.
"should get appointments by id" {
forall (
row( 1, -1, 200, "Robert Smith", true, "John Smith"),
row(99, 0, 404, null, null, null),
row( 1, 0, 304, null, null, null)
) { id, version, expStatus, expName, expType, expDocName ->
val response =
given()
.basePath("/api")
.contentType("application/json")
.header("If-None-Match", "\"$version\"")
.`when`()
.get("/appointments/$id")
.then()
.statusCode(expStatus)
if (expStatus == 200) {
response
.header("ETag", equalTo("\"0\""))
.body("name", equalTo(expName))
.body("doctor_name", equalTo(expDocName))
.body("private", equalTo(expType))
}
}
}
The noteworthy aspect above is how easy is to check your expectations against the received payload and the given-when-then structure provided by the tool. It will help even more in the case bellow, when we receive a JSON list from the server.
"should get appointments by date" {
val someDate = LocalDateTime.of(2019, 1,1, 1,0,0,0);
forall ( row(someDate, 200, 3, arrayOf("Robert Smith", "Another patient",
"David Bowie")),
row(now(), 200, 4, arrayOf("Lou Reed", "Another patient 2",
"David Gilmour", "Reger Waters")),
row(now().plusDays(1), 200, 0, arrayOf()) ) { date, expStatus, expNumElements, expNames ->
val response =
given()
.basePath("/api")
.contentType("application/json")
.`when`()
.get("/appointments?date=$date")
.then()
.statusCode(expStatus)
if (expStatus == 200) {
response
.body("size", equalTo(expNumElements))
.body("name", containsInAnyOrder(*expNames))
}
}
}
The '*' before expNames in the containsInAnyOrder is to make the array to be passed as varargs, as the function expects it. There are more handy ways to validate the received JSON and that can be found in the REST-assured documentation.
Clarity of the test code
For our test code look as clear as possible, you probably have to do less mock, less setup and perform the requests with no hidden tricks. Usually, the less indirections the better, the less setup in the code the better. It should be easy to reason about the code, so others can learn how the system works and the intents behind the test cases.
For the examples above, three things are clear:
- the sample data given to perform the request
- the way the request is performed
- the way that you assure your expectations
There is one hidden thing, that you have to correlate:
- you can see that data is being returned from the request, but how data was populated in the database? How do I know which codes and versions to use in the sample data in the forall rows? You can't figure it out right away and you have to correlate two artefacts (i. e. the test class and after migration script)
What about external dependencies?
Extra: POST with an external dependency
Now, let's explore the POST method and deal with an external service dependency. Those are the things we have to do:
- ensure that we catch server side validations
- ensure that the record has been stored properly
- ensure that the external service call is verified
Let's start looking at the class declaration and the extra components we are going to use.
@SpringBootTest(classes = [Application::class], webEnvironment = RANDOM_PORT)
class AppointmentPostIT(flyway: Flyway,
val mapper: ObjectMapper,
val emailService: EmailService,
val repository: AppointmentRepository,
@LocalServerPort port: String) : StringSpec() { init {
RestAssured.port = port.toInt()
RestAssured.baseURI = "http://localhost" // test methods here! flyway.clean()
flyway.migrate()
}
}
The Flyway object we use to cleanup the database after testing, as we are modifying the database state. If we do not clear the database, other test cases may break expectations.
The ObjectMapper will convert an appointment to JSON, in order to send it in the POST payload. To use an object will help to reduce the number of columns in the rows of the data driven table.
The EmailService is the external call we are going to mock. More on that later.
The AppointmentRepository is to retrieve the stored entity, after calling the POST method, to perform the assertions against the data we have just sent. Remember, use the GET operation to do that might not be a good idea.
init {
RestAssured.port = port.toInt()
RestAssured.baseURI = "http://localhost"
val locationIdRegex = """(?<=\/)([^\/]+)${'$'}""".toRegex()
"should create a new appointment" {
forall (
row(goodApointment, 201, "", arrayOf("Location",
"ETag")),
row(badEmailApointment, 400, "constraint [email]",
arrayOf()),
row(badDoctorApointment, 400, "Unable to find",
arrayOf())
) { appointment, expStatus, expMessage, expHeaders ->
val response =
given()
.basePath("/api")
.contentType("application/json")
.body( mapper.writeValueAsString(appointment) )
.expect()
.statusCode(expStatus)
.`when`()
.post("/appointments")
if (response.statusCode == 201) {
expHeaders.forEach {
response
.then()
.header(it, notNullValue())
} // => how to check if the appointment was stored ok?
} else {
response
.then()
.body(containsString(expMessage))
}
}
}
The above test is showing some technics to perform the POST testing. Starting with the appointment in the rows. Instead of using appointment properties to build the payload, we are passing an appointment object. The ObjectMapper is used to generate the JSON. Also, we have to build the sample objects by placing the following as variables of our test case:
val goodApointment = Appointment(now(), "Ella Fitzgerald",
"ella@ella.com", true, 1)
val badEmailApointment = goodApointment.copy(email = "")
val badDoctorApointment = goodApointment.copy(doctorId = 99)
The current assertions are pretty much like the ones we did in the GET integration test. Additionally, we are also checking the error messages in the payload in case of a non OK status. Now, to ensure that the appointment was stored properly, we must retrieve the stored appointment and compare it with the ones we have sent. See the following code snippet.
// getting the new ID from the location header
val match = locationIdRegex.find(response.header("Location"))
val id = match?.value// finding it on the database
val storedAppointment = appointmentRepository.getOne(id?.toLong()!!)// performing the assertions
with(storedAppointment) {
date shouldBe appointment.date
email shouldBe appointment.email
name shouldBe appointment.name
doctor?.id shouldBe appointment.doctorId
private shouldBe appointment.private
}
With that, we we have completed the standard POST test. A real scenario would have more examples, varying different inputs (rows in the forall loop).
Mock the EmailService
Looking at the sample application source code, is possible to find the POST method implementation in the class AppointmentHandler, which looks like:
fun create(req: ServerRequest) =
req.bodyToMono(Appointment::class.java)
.map { it.asEntity() }
.flatMap {
deferAsFuture { repository.saveAndFlush(it) }
}
.doOnSuccess {
emailService.send(it.email!!, "Appointment confirmation",
"Your appointment is confirmed to ${it.date}")
}
.flatMap {
val location = fromPath("/appointments/{id}")
.buildAndExpand(it.id).toUri()
created(location)
.eTag(it.version.toString())
.build()
}
Highlighted in bold is the e-mail service being used. The current implementation actually only logs it. A real world implementation might be sending the e-mail, or sending it to a mailing service or queue.
It would be great if we could ensure that the service has been called with the right arguments. That, without actually calling the real component, which is considered a boundary (a integration) in our sample application. See the updated POST the test code.
"should send an email to patient" {
given()
.basePath("/api")
.contentType("application/json")
.body( mapper.writeValueAsString(goodApointment) )
.`when`()
.post("/appointments")
.then()
.statusCode(201)
val captorTo = argumentCaptor<String>()
val captorSubj = argumentCaptor<String>()
val captorBody = argumentCaptor<String>()
verify(emailService, atLeastOnce())
.send(captorTo.capture(),
captorSubj.capture(),
captorBody.capture())
captorTo.firstValue shouldBe goodApointment.email
captorSubj.firstValue shouldBe "Appointment confirmation"
captorBody.firstValue shouldBe "Your appointment is confirmed to ${goodApointment.date}"
}
That verification is being done using Mockito. The argument captor had to be done with this useful mockito-kotlin library, which made possible to use the ArgumentCaptor against a method which does not accept null values.
Well, but there is something missing. We have auto-wired the e-mail service in the test class constructor, however, that would inject the current implementation if we do not tell Spring otherwise. Lets see how to provide the mock implementation.
companion object {
@Configuration
internal class ContextConfiguration {
@Bean
fun emailService(): EmailService = mock()
}
}
The complete POST test can be found here.
Wrapping up
I hope this story has provided you good insights on how to perform tests against your REST API. You can evolve the application and test other operations like DELETE and PUT.
Keep in mind that clarity is the key success factor for your test cases and for them to be understood by others.