How to create a REST API client and its integration tests in Kotlin Multiplatform

Jorge Sánchez (xurxodev)
Kt. Academy
Published in
8 min readMar 1, 2019

--

Kotlin Multiplatform Library

This article is an English translation from my original article in my blog xurxodev.com.

Subscribe to my Spanish newsletter where I write stories about my vision of the software development: https://xurxodev.com/#/portal/signup

Most of the mobile applications are based on communication with an API Rest.

The integration with an external service is fundamental in mobile development. To have an optimal integration, it is necessary to ensure it by integration tests.

With the appearance of Kotlin Multiplatform, there is a very interesting scenario like having a client of a REST API in a multiplatform library and that we can use it from both an Android application and iOS application.

In this blog post, we will review how we can create a REST API client using Kotlin Multiplatform and how to create the integration tests needed to verify that our integration works correctly.

A few weeks ago I participated in a mobile testing training of Karumi where we made this kata, we will review a Kotlin Multiplatform version.

API REST

The client that we are going to create communicates with the following web service: http://jsonplaceholder.typicode.com/

This REST API manages tasks, you will be able to get all the existing tasks, get a task using its identifier, add a new task, update a task or delete an existing task.

Creating the Gradle project

The first thing to do is to create the project, you can use Intellij or Android Studio.

The detail explanation of how to configure a Kotlin Multiplatform Library is out of the scope of this post but, here yo have the official documentation of Jetbrains: Multiplatform Kotlin library.

Setting up the continuous integration

The best time to set up CI for a project is at the beginning, so this would be the next step.

I’ve used Travis, all you have to do is going to https://travis-ci.org, enabling your Github project for continuous integration and adding a travis.yml file in the root directory of the project:

os: osx
osx_image: xcode10.1
script:
- ./gradlew build

In this file, we indicate OS X as the operating system where to perform the build.

This is necessary to execute the tests that we are going to create in an iOS emulator.

And finally, we indicate the script command to run the build task.

Among other subtasks, build task will compile the code of the library for the JVM, also for iOS and finally, It will run the tests on both platforms.

Creating the client

Libraries to use

We need the next libraries:

  • Ktor, it’s a multiplatform library to make requests to a remote service. It’s similar to Retrofit for Android or Alamofire and AFNetworking for iOS.
  • Kotlinx Serialization, it’s a multiplatform library to serialize and to deserialize, JSON is one of the supported formats.

TodoApiClient

Our client contains a HttpClient to make requests and receive by constructor a HttpClientEngine, we will see why further on.

The engine can be assigned explicitly or implicitly by artifacts you have included in the build.gradle file.

There are some engines for JVM like Apache, Jetty, OkHttp; for iOS it only exists one iOS engine, it uses the asynchronous NSURLSession internally and it has no additional configuration.

class TodoApiClient constructor(      
httpClientEngine: HttpClientEngine? = null) {
companion object {
const val BASE_ENDPOINT =
"http://jsonplaceholder.typicode.com"
}

private val client: HttpClient =
HttpClient(httpClientEngine!!) {
install(JsonFeature) {
serializer = KotlinxSerializer().apply {
register(Task.serializer())
}
}
}

We are going to have a method for each of the actions that the remote service allows, let’s see some:

suspend fun getAllTasks(): Either<ApiError, List<Task>> = try {
val tasksJson = client.get<String>("$BASE_ENDPOINT/todos")

// JsonFeature does not working currently with root-level array
// https://github.com/Kotlin/kotlinx.serialization/issues/179
val tasks = Json.nonstrict.parse(Task.serializer().list,
tasksJson)

Either.Right(tasks)
} catch (e: Exception) {
handleError(e)
}

suspend fun addTask(task: Task): Either<ApiError, Task> = try {
val taskResponse = client.post<Task>("$BASE_ENDPOINT/todos") {
contentType(ContentType.Application.Json)
body = task
}

Either.Right(taskResponse)
} catch (e: Exception) {
handleError(e)
}

Notice that every method returns an Either type, remember that it is a functional programming technique to deal with errors without the use of exceptions.

In more traditional oriented object programming, each of these methods could return exceptions.

If the result is satisfactory, we return the right type of the corresponding generic.

In case of an error, Ktor returns an exception that we handle in its own method:

private fun handleError(exception: Exception): Either<ApiError, Nothing> =
if (exception is BadResponseStatusException) {
if (exception.statusCode.value == 404) {
Either.Left(ItemNotFoundError)
} else {
Either.Left(UnknownError(exception.statusCode.value))
}
} else {
Either.Left(NetworkError)
}

Integration tests

To test the integration of our client with the Rest API, we need to verify the following:

  • The requests are sent correctly to the API: endpoint, verb, headers, body if applicable.
  • The server responses are parsed correctly.
  • The server error responses are handled correctly.

To perform these checks we have to simulate server responses and to be able to access in some way the HTTP requests that we have sent.

Libraries to use

We need the next libraries:

  • ktor-client-mock, it’s a multiplatform library that exposes MockEngine and allows us to simulate server responses and to access the sent requests to perform validations.
  • Kotlinx coroutines, Ktor is based on suspending functions and for this reason, we need the coroutines library to invoke our client from the tests.

Creating the tests

Let’s see some tests that we can create.

The first tests we could try are to verify the todos endpoint, for example:

  • Verify the response is parsed correctly.
  • Verify that accept header is sent.
  • Verify in case of an error that it is processed properly.

What infrastructure do we need? We need to have a way to configure a MockEngine where we can simulate a response and pass this MockEgine to our client in the constructor instead of a real one.

We need a JSON that represents the server’s response, the simplest approach would be to have a function that returns the JSON string:

fun getTasksResponse() =
"[{\n" +
" \"userId\": 1,\n" +
" \"id\": 1,\n" +
" \"title\": \"delectus aut autem\",\n" +
" \"completed\": false\n" +
"}," +
" {\n" +
" \"userId\": 1,\n" +
" \"id\": 2,\n" +
" \"title\": \"quis ut nam facilis et officia qui\",\n" +
" \"completed\": false\n" +
"}, " +
"{\n" +
" \"userId\": 2,\n" +
" \"id\": 3,\n" +
" \"title\": \"fugiat veniam minus\",\n" +
" \"completed\": false\n" +
"}," +
"{\n" +
" \"userId\": 2,\n" +
" \"id\": 4,\n" +
" \"title\": \"et porro tempora\",\n" +
" \"completed\": true\n" +
"}]"

Now we need to be able to configure a Mock Engine to return stub responses and to use this one to access the sent request to realize validations about it.

We can create a base class for our tests or create a specific class to realize this work. I like to favour the composition over the inheritance also in the tests.

class TodoApiMockEngine {
private lateinit var mockResponse: MockResponse
private var lastRequest: MockHttpRequest? = null

fun enqueueMockResponse(
endpointSegment: String,
responseBody: String,
httpStatusCode: Int = 200
) {
mockResponse = MockResponse(endpointSegment, responseBody, httpStatusCode)
}

fun get() = MockEngine {
lastRequest = this

when (url.encodedPath) {
"${mockResponse.endpointSegment}" -> {
MockHttpResponse(
call,
HttpStatusCode.fromValue(
mockResponse.httpStatusCode),
ByteReadChannel(mockResponse.responseBody
.toByteArray(Charsets.UTF_8)),
headersOf(HttpHeaders.ContentType to listOf(
ContentType.Application.Json.toString()))
)
}
else -> {
error("Unhandled ${url.fullPath}")
}
}
}

fun verifyRequestContainsHeader(key: String,
expectedValue: String) {
val value = lastRequest!!.headers[key]
assertEquals(expectedValue, value)
}

fun verifyRequestBody(addTaskRequest: String) {
val body = (lastRequest!!.content as TextContent).text

assertEquals(addTaskRequest, body)
}

fun verifyGetRequest() {
assertEquals(HttpMethod.Get.value,
lastRequest!!.method.value)
}

fun verifyPostRequest() {
assertEquals(HttpMethod.Post.value,
lastRequest!!.method.value)
}

fun verifyPutRequest() {
assertEquals(HttpMethod.Put.value,
lastRequest!!.method.value)
}

fun verifyDeleteRequest() {
assertEquals(HttpMethod.Delete.value,
lastRequest!!.method.value)
}
}

As you can see in this class, in the get function, we configure a MockEngine where depending on the encoded path, we will return a response with an HTTP status code and a body that it’s been passed in the enqueueMockResponse method.

If the encodedPath does not match, we throw an error:

error("Unhandled ${url.fullPath}")

In this way it is not necessary that we create a specific test for each endpoint that validates that the endpoint of the sent request is correct, it will be validated in each test implicitly.

And finally we write our tests:

class TodoApiClientShould {
companion object {
private const val ALL_TASK_SEGMENT = "/todos"
}

private val todoApiMockEngine = TodoApiMockEngine()

@Test
fun `send accept header`() = runTest {
val apiClient = givenAMockTodoApiClient(
ALL_TASK_SEGMENT, getTasksResponse())

apiClient.getAllTasks()

todoApiMockEngine
.verifyRequestContainsHeader("Accept", "application/json")
}

@Test
fun `send request with get http verb getting all task`() =
runTest {
val apiClient = givenAMockTodoApiClient(ALL_TASK_SEGMENT,
getTasksResponse())

apiClient.getAllTasks()

todoApiMockEngine.verifyGetRequest()
}

@Test
fun `return tasks and parses it properly`() = runTest {
val apiClient = givenAMockTodoApiClient(ALL_TASK_SEGMENT,
getTasksResponse())

val tasksResponse = apiClient.getAllTasks()

tasksResponse.fold(
{ left -> fail("Should return right but was left:
$left") },
{ right ->
assertEquals(4, right.size.toLong())
assertTaskContainsExpectedValues(right[0])
})
}

@Test
fun `return http error 500 if server response internal server
error getting all task`() =
runTest {
val apiClient =givenAMockTodoApiClient(ALL_TASK_SEGMENT,
httpStatusCode = 500)

val tasksResponse = apiClient.getAllTasks()

tasksResponse.fold(
{ left -> assertEquals(UnknownError(500), left) },
{ right -> fail("Should return left but was right:
$right") })
}
private fun assertTaskContainsExpectedValues(task: Task?) {
assertTrue(task != null)
assertEquals(task.id, 1)
assertEquals(task.userId, 1)
assertEquals(task.title, "delectus aut autem")
assertFalse(task.completed)
}

private fun givenAMockTodoApiClient(
endpointSegment: String,
responseBody: String = "",
httpStatusCode: Int = 200): TodoApiClient {

todoApiMockEngine.enqueueMockResponse(endpointSegment,
responseBody, httpStatusCode)

return TodoApiClient(todoApiMockEngine.get())
}
}

Notice that in the tests we return runTest, this is where the coroutines come into play.

The intention is to run the tests with runBlocking to execute the test synchronously.

As we are in the common module of a multiplatform project we do not have this builder available, so what we have to do is to create an abstraction and define its implementation within the source set of each platform following the expect/actual mechanism:

// This is within CommonTest source test     
internal expect fun <T> runTest(block: suspend () -> T): T
// This is within JvmTest source test
internal actual fun <T> runTest(block: suspend () -> T): T {
return runBlocking { block() }
}
// This is within iosTest source test
internal actual fun <T> runTest(block: suspend () -> T): T {
return runBlocking { block() }
}

¿Why the tests are not executed for iOS?

When you are running the build for the first time, the tests are not executed for iOS.

This is because the plugin by default only supports the execution of tests for macOS, Windows, etc.

But we can create a Gradle task that executes it easily.

task iosTest {
doLast {
def binary = kotlin.targets.iOS.compilations.test.getBinary('EXECUTABLE', 'DEBUG')
exec {
commandLine 'xcrun', 'simctl', 'spawn', "iPhone XR", binary.absolutePath
}
}
}
tasks.check.dependsOn iosTest

Kata and source code

You can find the source code here.

The master branch contains all the kata solved by me.

The best way to learn is by practising so I recommend using the integration-testing-kotlin-multiplatform-kata branch and you make the exercise by yourself.

Related Course

Testing Course

Conclusions

In this post, we have seen how to create a multiplatform library that contains a REST API client and also how to create tests that allow us to validate the integration with the remote service and run them over JVM and iOS emulator.

But this is just the example of a kata, it is not a library ready to release into production.

Thanks for reading this article! If you liked it, press the 👏🏼 button so other people can read it too :)

--

--