Como criar um cliente REST API e seus testes de integração no Kotlin Multiplatform

Tempo de leitura: 6 minutes

A maioria dos aplicativos móveis é baseada na comunicação com um API Rest.

A integração com um serviço externo é fundamental no desenvolvimento móvel. Para ter uma integração ótima, é necessário garanti-la por meio de testes de integração.

Com o surgimento da Multiplataforma Kotlin, existe um cenário muito interessante como ter um cliente de uma API REST em uma biblioteca multiplataforma e que podemos usá-la tanto em um aplicativo Android quanto em um aplicativo iOS.

Nesta postagem do blog, revisaremos como podemos criar um cliente REST API usando Kotlin Multiplatform e como criar os testes de integração necessários para verificar se nossa integração funciona corretamente.

Há algumas semanas participei de um treinamento de teste móvel do Karumi onde fizemos este kata, revisaremos uma versão Multiplataforma do Kotlin.

 

API REST

O cliente que vamos criar se comunica com o seguinte serviço da web:http://jsonplaceholder.typicode.com/

Esta API REST gerencia tarefas, você será capaz de obter todas as tarefas existentes, obter uma tarefa usando seu identificador, adicionar uma nova tarefa, atualizar uma tarefa ou excluir uma tarefa existente.

 

Criação do projeto Gradle

A primeira coisa a fazer é criar o projeto, você pode usar Intellij ou Android Studio.

A explicação detalhada de como configurar uma Biblioteca Multiplataforma Kotlin está fora do escopo deste post, mas aqui você tem a documentação oficial do Jetbrains: Biblioteca Kotlin Multiplataforma.

 

Configurando a integração contínua

O melhor momento para configurar o IC para um projeto é no início, então esta seria a próxima etapa.

Eu usei o Travis, tudo o que você precisa fazer é acessar https://travis-ci.org, habilitando seu projeto Github para integração contínua e adicionando um arquivo travis.yml no diretório raiz do projeto:

os: osx
  osx_image: xcode10.1

script:
  - ./gradlew build

Neste arquivo, indicamos OS X como o sistema operacional onde realizar a compilação.

Isso é necessário para executar os testes que vamos criar em um emulador iOS.

E finalmente, indicamos o comando de script para executar a tarefa de construção.

Entre outras subtarefas, a tarefa de construção compilará o código da biblioteca para o JVM, também para iOS e por fim, executará os testes nas duas plataformas.

 

Criando o cliente

Bibliotecas para usar

Precisamos das próximas bibliotecas:

  • Ktor, é uma biblioteca multiplataforma para fazer solicitações a um serviço remoto. É semelhante ao Retrofit para Android ou Alamofire e AFNetworking para iOS.
  • Serialização Kotlinx, é uma biblioteca multiplataforma para serializar e desserializar, JSON é um dos formatos suportados.

TodoApiClient

Nosso cliente contém um HttpClient para fazer solicitações e receber pelo construtor um HttpClientEngine, veremos o porquê mais adiante.

O mecanismo pode ser atribuído explicitamente ou implicitamente por artefatos que você incluiu no arquivo build.gradle.

Existem alguns motores para JVM como Apache, Jetty, OkHttp; para iOS existe apenas um motor iOS, usa a NSURLSession assíncrona internamente e não tem configuração adicional.

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

Teremos um método para cada uma das ações que o serviço remoto permite, vamos ver alguns:

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

    // JsonFeature não funciona atualmente com array de nível raiz
    // 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)
}

Observe que todo método retorna um tipo Either, lembre-se de que é uma técnica de programação funcional para lidar com erros sem o uso de exceções.

Na programação orientada a objetos mais tradicional, cada um desses métodos pode retornar exceções.

Se o resultado for satisfatório, retornamos o tipo certo do genérico correspondente.

Em caso de erro, Ktor retorna uma exceção que tratamos em seu próprio método:

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

 

Testes de integração

Para testar a integração do nosso cliente com a API Rest, precisamos verificar o seguinte:

  • As solicitações são enviadas corretamente para a API: endpoint, verb, headers, body se aplicável.
  • As respostas do servidor são analisadas corretamente.
  • As respostas de erro do servidor são tratadas corretamente.

Para realizar essas verificações, temos que simular as respostas do servidor e poder acessar de alguma forma as solicitações HTTP que enviamos.

 

Bibliotecas para usar

Precisamos das próximas bibliotecas:

  • ktor-client-mock, é uma biblioteca multiplataforma que expõe o MockEngine e nos permite simular as respostas do servidor e acessar as solicitações enviadas para realizar as validações.
  • Corrotinas Kotlinx, Ktor é baseado em funções suspensas e por este motivo, precisamos da biblioteca de corrotinas para invocar nosso cliente dos testes.

 

Criando os testes

Vamos ver alguns testes que podemos criar.

Os primeiros testes que podemos tentar são verificar o endpoint Todos, por exemplo:

  • Verifique se a resposta foi analisada corretamente.
  • Verifique se o cabeçalho de aceitação foi enviado.
  • Verifique em caso de erro se o processamento está correto.

De que infraestrutura precisamos? Precisamos ter uma maneira de configurar um MockEngine onde possamos simular uma resposta e passar este MockEgine para nosso cliente no construtor em vez de um real.

Precisamos de um JSON que represente a resposta do servidor, a abordagem mais simples seria ter uma função que retorne a string JSON:

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" +
        "}]"

Agora precisamos ser capazes de configurar um Mock Engine para retornar respostas de stub e usar este para acessar a solicitação enviada para realizar validações sobre ela.

Podemos criar uma classe base para nossos testes ou criar uma classe específica para realizar este trabalho. Gosto de privilegiar a composição sobre a herança também nos testes.

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 você pode ver nesta classe, na função get, configuramos um MockEngine onde, dependendo do caminho codificado, retornaremos uma resposta com um código de status HTTP e um corpo que foi passado no método enqueueMockResponse.

Se encodedPath não corresponder, geraremos um erro:

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

Desta forma, não é necessário criarmos um teste específico para cada endpoint que valide se o endpoint da solicitação enviada está correto, ele será validado em cada teste implicitamente.

E finalmente escrevemos nossos testes:

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("Devia voltar à direita, mas foi à esquerda: 
                           $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())
    }
}

Observe que nos testes que retornamos runTest, é aqui que as corrotinas entram em ação.

A intenção é executar os testes com runBlocking para executar o teste de forma síncrona.

Como estamos no módulo comum de um projeto multiplataforma, não temos este construtor disponível, então o que temos que fazer é criar uma abstração e definir sua implementação dentro do conjunto de origem de cada plataforma seguindo o mecanismo esperado/real:

// Isso está dentro do teste de origem CommonTest
internal expect fun <T> runTest(block: suspend () -> T): T  

// Isso está dentro do teste de origem JvmTest  
internal actual fun <T> runTest(block: suspend () -> T): T {            
    return runBlocking { block() }     
}

// Isso está dentro do teste de origem iosTest  
internal actual fun <T> runTest(block: suspend () -> T): T {      
   return runBlocking { block() }
}

¿Por que os testes não são executados para iOS?

Quando você está executando o build pela primeira vez, os testes não são executados para iOS.

Isso ocorre porque o plug-in, por padrão, só suporta a execução de testes para macOS, Windows, etc.

Mas podemos criar uma tarefa Gradle que o execute facilmente.

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 e código fonte

Você pode encontrar o código-fonte aqui. (Fonte do Autor)

O ramo mestre contém todos os kata resolvidos pelo Autor.

A melhor maneira de aprender é praticando, então eu recomendo usar o branch integration-testing-kotlin-multiplatform-kata e você mesmo fazer o exercício.

 

Conclusões

Neste artigo, vimos como criar uma biblioteca multiplataforma que contém um cliente REST API e também como criar testes que nos permitem validar a integração com o serviço remoto e executá-los em JVM e emulador de iOS.

Mas este é apenas o exemplo de um kata, não é uma biblioteca pronta para ser lançada em produção.