Cómo crear un cliente API REST y sus tests de integración en Kotlin Multiplatform

La mayoría de las aplicaciones móviles se basa en una comunicación con un API Rest.

Tener una buena integración con un servicio externo es fundamental en el desarrollo móvil. Para tener una integración óptima es necesario asegurarse del correcto funcionamiento mediante tests de integración.

Con la aparición de Kotlin multiplatform, aparece un escenario muy interesante como es tener un cliente de un API REST en una librería multiplataforma y que podamos utilizarlo tanto desde una aplicación Android como desde una aplicación iOS.

En este artículo vamos a ver cómo podemos crear un cliente API REST utilizando Kotlin multiplatform y cómo poder crear los test de integración necesarios para verificar que nuestra integración funciona correctamente.

Hace unas semanas participe en un curso de mobile testing de Karumi y realizamos este kata. Vamos ver como sería la versión para Kotlin multiplatform.

API Rest

El cliente que vamos a crear se comunica con el siguiente servicio web: http://jsonplaceholder.typicode.com/

Este API REST gestiona tareas, vas a poder obtener todas las tareas existentes, obtener una tarea utilizando su identificador, añadir una nueva tarea, actualizar una tarea o eliminar una tarea existente.

Creando el proyecto gradle

Lo primero es crear el proyecto, podrás utilizar Intellij o Android Studio.

Explicar con detalle como configurar una libraría multiplatforma en Kotlin se escapa del alcance de este artículo pero, aquí dejo la documentación oficial de Jetbrains: Multiplatform Kotlin library.

Configurando la integración continua

El mejor momento para configurar la integración contínua en un proyecto es al principio, así que sería el siguiente paso.

Yo he utilizado Travis, lo único que hay que hacer es ir a https://travis-ci.org, habilitar tu proyecto Github para integración continua y añadir en el directorio raiz del proyecto un fichero travis.yml:

  os: osx
  osx_image: xcode10.1

  script:
  - ./gradlew build

En este fichero lo que indicamos es que la maquina a levantar en travis sea OS X. Esto es necesario para poder ejecutar los test que vamos a crear en un emulador ios.

Y finalmente le indicamos que ejecute la tarea build.

Entre otras subtareas, build compilará el código de la libraría para la JVM, también para iOS y por último ejecutará los test en ambas plataformas.

Creando el cliente

Librerías a utilizar

Necesitamos las siguientes librerías:

  • Ktor, es una librería mutiplataforma para realizar peticiones a un servicio remoto. Sería como Retrofit en Android o en iOS como Alamofire y AFNetworking.
  • Kotlinx Serialization, es una librería multiplatforma para serializar y deserializar, uno de los formatos que admite es JSON.

TodoApiClient

Nuestro cliente se va a componer de un HttpClient, con el que realizaremos la peticiones y recibirá un HttpClientEngine en el contructor, veremos por qué más adelante.

El engine se puede establecer explícitamente o implícitamente mediante la declaración de dependencias en el fichero build.gradle.

Algunos de los engine que podemos utilizar son para jvm Apache, Jetty, OkHttp y para iOS solo existe un engine, utiliza internamente NSURLSession de forma asíncrona y no es configurable.

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())
            }
        }
    }

Vamos a tener un método por cada una de las acciones que nos permite el servicio remoto, veamos alguna:

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

        Either.Right(task)
    } 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)
    }

Fijaros que devuelve un tipo Either, recordad que es una técnica de la programación funcional para tratar los errores sin el uso de excepciones.

Si nos lo llevaramos a la orientación a objetos más tradicional, cada uno de estos métodos podría devolver excepciones.

Si el resultado es satisfactorio devolvemos un tipo right del genérico que corresponda.

En caso error, Ktor nos devuelve una excepción que tratamos en el método handleError:

    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)
        }

Tests de integración

Para probar la integración de nuestro cliente con el API Rest necesitamos verificar lo siguiente:

  • Se envían correctamente las peticiones a la API: endpoint, verbo, cabeceras, body si corresponde.
  • Se parsean correctamente las respuestas del servidor.
  • Se manejan las respuestas de error del servidor correctamente.

Para poder realizar estas verificaciones tenemos que tener la capacidad de simular respuestas de servidor y poder acceder de algún modo a las peticiones http que se envían.

Librerías a utilizar

Necesitamos las siguientes librerías:

  • ktor-client-mock, es una librería mutiplataforma que expone un MockEngine y nos va a permitir simular respuestas del servidor y acceder a las request realizadas para realizar validaciones.
  • Kotlinx coroutines, Ktor se basa en funciones suspendidas por lo tanto necesitamos la librería de corutinas para poder invocar nuestro cliente desde los tests.

Creando los tests

Vamos a ver algunos test de los que podemos crear.

Lo primero que podríamos probar es verificar el endpoint /todos:

  • Verificar que la respuesta es parseada correctamente.
  • Verificar que la cabecera accept es enviada correctamente
  • Verificar que en caso de error, este es procesado adecuadamente.

¿Que infraestructura necesitamos?, necesitamos tener una forma de configurar un MockEngine donde podamos simular una respuesta y pasar este MockEgine a nuestro cliente en el constructor en lugar de uno real.

Necesitamos un JSON que represente la respuesta del servidor, lo más simple sería tener una función que nos devuelve el JSON como 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" +
        "}]"

Ahora necesitamos poder configurar un MockEngine para devolver la respuestas moqueadas y utilizar este engine para acceder a la request realizada y realizar validaciones sobre ella.

Podemos crearnos una clase base para nuestros tests que se encargue de ello o crear una clase específica. A mí me gusta más favorecer la composición sobre la herencia también en los test.

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)
    }

Como se puede apreciar en esta clase, en la función get, se configura un MockEngine donde en función del encoded path, vamos a devolver una respuesta con un http status code y un body que nos pasan en el método enqueueMockResponse.

Si no coincidide el encodedPath, lanzamos un error error("Unhandled ${url.fullPath}").

De esta forma no es necesario que creemos un test espefifico para cada endpoint que valide que el endpoint de la petición enviada es el correcto, se va a estar validando en cada test implícitamente.

Y por último escribimos nuestros 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())
    }
}    

Fijaros que en los tests devolvemos runTest, aquí es donde entran en juego las corutinas.

La intención es ejecutar con runBlocking los test para que se ejecuten síncronamente.

Como estamos en el módulo common de un proyecto multiplataforma no tenemos disponible este builder, entonces lo que tenemos que hacer es crearnos una abstracción y definir su implementación dentro del sourceset de cada plataforma siguiendo el mecanismo expect/actual:

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

¿Por qué no se ejecutan los test en iOS?

Al ejecutar los test la primera vez lo que ocurre es que no se ejecutan en ios.

Esto es debido a que el plugin por defecto solo soporta la ejecución de test para macOS, Windows etc..

Pero nos podemos crear una tarea Gradle que se encarge de ello fácilmente.

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 y código fuente

El código completo lo puedes encontrar aquí.

La rama master contiene todo el kata resuelto por mí.

Pero la mejor forma de aprender es prácticando así que te recomiendo que utilices la rama integration-testing-kotlin-multiplatform-kata y realizes el ejercicio tu mismo.

Conclusiones

En este artículo hemos visto cómo se puede crear una libraría multiplataforma en Kotlin que consiste en un cliente de un API REST y también cómo crear tests que nos sirvan para validar la integración con el servicio remoto y se ejecuten tanto en la jvm como en un emulador iOS.

Pero esto es solo el ejemplo de un kata, no es una librería que este ni mucho menos lista para poner en producción.