Resposta rápida: para testar uma API Ktor sem subir um servidor externo, adicione ktor-server-test-host e execute o módulo dentro de testApplication. Use o client fornecido pelo ambiente para fazer requisições HTTP reais contra a aplicação em memória e validar status, headers e corpo. Mantenha regras de negócio em testes unitários rápidos, use fakes ou mocks nas dependências das rotas e reserve PostgreSQL, Redis e brokers reais via Testcontainers para testes de integração que precisam comprovar SQL, migrations e configuração. No CI, rode primeiro testes unitários, depois integração, e nunca compartilhe estado mutável entre casos.

Testar somente funções de service não prova que a API está correta. Uma rota pode usar o método HTTP errado, esquecer ContentNegotiation, devolver 500 no lugar de 404, aceitar um JWT inválido ou serializar um campo com nome inesperado. Ao mesmo tempo, iniciar a aplicação completa com banco e infraestrutura em cada teste deixa a suíte lenta e instável.

A estratégia mais eficiente combina camadas. Este guia mostra como usar testApplication, configurar um cliente JSON, injetar dependências, testar autenticação e erros, decidir entre fake e MockK, integrar Testcontainers e evitar os problemas que costumam aparecer quando a suíte chega ao pipeline. Se você ainda está montando a API, comece pelo tutorial de APIs REST com Ktor e Kotlin e volte aqui antes de publicar os primeiros endpoints.

O que deve ser testado em uma API Ktor?

Uma API backend possui responsabilidades diferentes, e cada uma pede um tipo de teste:

CamadaO que validarFerramenta principal
Domínio e casos de usoregras, cálculos, estados e permissõesKotlin Test, JUnit e fakes
Rota HTTPmétodo, path, status, headers e JSONtestApplication
Plugins do Ktorautenticação, serialização, StatusPages, CORStestApplication
PersistênciaSQL, constraints, migrations e transaçõesTestcontainers
Integrações externascontrato e tratamento de falhasfake, servidor stub ou sandbox
Fluxo ponta a pontaaplicação e infraestrutura trabalhando juntaspoucos testes de sistema

Não existe vantagem em testar uma soma pura através de HTTP e PostgreSQL. Também é insuficiente testar apenas o service quando o risco está na borda HTTP. Coloque cada verificação na camada mais baixa que ainda consegue detectar o defeito relevante.

Uma pirâmide saudável costuma ter muitos testes unitários, uma quantidade moderada de testes de rota e integração e poucos testes end-to-end. A distribuição exata depende do produto, mas o princípio evita uma suíte que demora vinte minutos para informar que um if simples está errado.

Dependências para testar Ktor

Mantenha os artefatos do Ktor na mesma versão usada pelo servidor. Um bloco típico no Gradle Kotlin DSL fica assim:

dependencies {
    implementation("io.ktor:ktor-server-core:<versao-atual>")
    implementation("io.ktor:ktor-server-netty:<versao-atual>")
    implementation("io.ktor:ktor-server-content-negotiation:<versao-atual>")
    implementation("io.ktor:ktor-serialization-kotlinx-json:<versao-atual>")

    testImplementation("io.ktor:ktor-server-test-host:<versao-atual>")
    testImplementation("io.ktor:ktor-client-content-negotiation:<versao-atual>")
    testImplementation("io.ktor:ktor-serialization-kotlinx-json:<versao-atual>")
    testImplementation(kotlin("test"))
}

Se o time usa JUnit 5 e MockK:

dependencies {
    testImplementation("org.junit.jupiter:junit-jupiter:<versao-atual>")
    testImplementation("io.mockk:mockk:<versao-atual>")
}

tasks.test {
    useJUnitPlatform()
}

Não copie versões isoladas de snippets antigos. Centralize Ktor, JUnit, MockK e Testcontainers no Version Catalog e atualize por pull request. O guia de Gradle com Kotlin explica como manter esse catálogo em projetos com vários módulos.

Primeiro teste com testApplication

Imagine uma rota simples de health check:

fun Application.module() {
    routing {
        get("/health") {
            call.respond(
                HttpStatusCode.OK,
                mapOf("status" to "up")
            )
        }
    }
}

O teste abre a aplicação em memória e usa um cliente HTTP integrado:

import io.ktor.client.request.get
import io.ktor.http.HttpStatusCode
import io.ktor.server.testing.testApplication
import kotlin.test.Test
import kotlin.test.assertEquals

class HealthRouteTest {

    @Test
    fun `health deve responder ok`() = testApplication {
        application {
            module()
        }

        val response = client.get("/health")

        assertEquals(HttpStatusCode.OK, response.status)
        assertEquals(
            "application/json; charset=UTF-8",
            response.headers["Content-Type"]
        )
    }
}

testApplication não faz uma chamada para localhost nem exige escolher uma porta. O test host executa a pipeline do Ktor e permite testar routing e plugins sem o custo de um processo externo. A requisição, porém, continua atravessando a borda HTTP da aplicação: isso detecta erros que uma chamada direta ao handler não encontraria.

Evite reutilizar uma aplicação global entre todos os testes. Cada caso deve começar com estado previsível, especialmente quando plugins, caches ou repositórios em memória podem ser modificados.

Configurando um cliente para JSON

Para enviar e receber objetos Kotlin, crie um cliente com ContentNegotiation:

val testClient = createClient {
    install(ContentNegotiation) {
        json(
            Json {
                ignoreUnknownKeys = false
                explicitNulls = true
            }
        )
    }
}

Considere um endpoint de criação:

@Serializable
data class CreateTaskRequest(
    val title: String,
)

@Serializable
data class TaskResponse(
    val id: String,
    val title: String,
    val completed: Boolean,
)

O teste pode enviar o body e desserializar a resposta:

@Test
fun `post tasks deve criar tarefa`() = testApplication {
    application {
        taskModule(repository = InMemoryTaskRepository())
    }

    val testClient = createClient {
        install(ContentNegotiation) { json() }
    }

    val response = testClient.post("/tasks") {
        contentType(ContentType.Application.Json)
        setBody(CreateTaskRequest(title = "Publicar API"))
    }

    assertEquals(HttpStatusCode.Created, response.status)

    val body = response.body<TaskResponse>()
    assertEquals("Publicar API", body.title)
    assertEquals(false, body.completed)
}

Valide mais que o status quando o contrato importa. Confira o payload, Content-Type, Location, paginação e headers de cache conforme a rota. Não faça assertions sobre campos aleatórios, como um UUID gerado, quando basta verificar formato e presença.

Torne o módulo testável por injeção de dependência

Uma função module() que cria banco, cliente externo e services internamente é difícil de testar. Prefira receber as dependências:

interface TaskRepository {
    suspend fun create(title: String): Task
    suspend fun findById(id: String): Task?
}

fun Application.taskModule(
    repository: TaskRepository,
) {
    install(ContentNegotiation) {
        json()
    }

    install(StatusPages) {
        exception<ValidationException> { call, error ->
            call.respond(
                HttpStatusCode.BadRequest,
                mapOf("error" to error.message)
            )
        }
    }

    routing {
        taskRoutes(repository)
    }
}

A entrada de produção monta as implementações reais:

fun Application.module() {
    val repository = PostgresTaskRepository(
        dataSource = configureDataSource(environment.config)
    )

    taskModule(repository)
}

O teste injeta InMemoryTaskRepository, sem mudar as rotas. Essa separação também melhora a arquitetura do deploy: construção de infraestrutura fica fora da regra HTTP.

Em projetos maiores, Koin, Dagger ou outro container pode montar as dependências. Ainda assim, preserve uma forma explícita de substituir módulos no teste. O guia de Koin vs Hilt com Kotlin é focado no ecossistema Kotlin/Android, mas a decisão central também vale no backend: dependências devem ser substituíveis sem esconder o grafo inteiro.

Fake ou MockK: qual escolher?

Um fake é uma implementação funcional e simplificada. Um mock registra chamadas e devolve respostas configuradas.

Use fake quando:

  • vários testes precisam do mesmo comportamento;
  • o estado em memória deixa o cenário mais legível;
  • você quer testar criação seguida de consulta;
  • a interface é estável e pequena.

Use MockK quando:

  • precisa simular uma falha específica;
  • quer verificar que uma operação externa não foi chamada;
  • a implementação fake seria trabalhosa e sem valor próprio;
  • o teste depende de uma interação pontual.

Exemplo com MockK:

@Test
fun `post inválido não deve chamar repositório`() = testApplication {
    val repository = mockk<TaskRepository>()

    application {
        taskModule(repository)
    }

    val response = client.post("/tasks") {
        contentType(ContentType.Application.Json)
        setBody("""{"title":""}""")
    }

    assertEquals(HttpStatusCode.BadRequest, response.status)
    coVerify(exactly = 0) {
        repository.create(any())
    }
}

Não transforme cada teste em uma lista de verify sobre detalhes internos. Se uma refatoração correta quebra dezenas de mocks sem mudar comportamento, a suíte está acoplada à implementação. O artigo de JUnit 5 e MockK com Kotlin aprofunda esse equilíbrio.

Testando validação e StatusPages

APIs precisam de respostas previsíveis também nos erros. Cubra pelo menos:

  • JSON malformado;
  • campo obrigatório ausente;
  • valor inválido;
  • recurso inexistente;
  • conflito de unicidade;
  • exceção inesperada sem vazamento de stack trace.

Um contrato de erro explícito facilita assertions:

@Serializable
data class ApiError(
    val code: String,
    val message: String,
)
@Test
fun `get inexistente deve responder 404`() = testApplication {
    application {
        taskModule(InMemoryTaskRepository())
    }

    val response = client.get("/tasks/nao-existe")

    assertEquals(HttpStatusCode.NotFound, response.status)
    assertEquals(
        "task_not_found",
        response.body<ApiError>().code
    )
}

Não compare a mensagem inteira se ela é texto de apresentação sujeito a revisão. Dê prioridade a um code estável e teste detalhes da mensagem somente quando fizerem parte do contrato público.

Garanta também que uma exceção desconhecida devolva uma mensagem genérica ao cliente, enquanto o logger recebe o contexto técnico. Senhas, tokens, SQL e stack traces nunca devem aparecer no JSON de produção.

Testando autenticação JWT

O teste de uma rota protegida deve cobrir mais que o caminho feliz:

  1. sem token → 401;
  2. token malformado → 401;
  3. assinatura inválida → 401;
  4. token expirado → 401;
  5. identidade válida sem permissão → 403;
  6. identidade autorizada → resposta esperada.

Use uma configuração exclusiva de teste e gere tokens durante o próprio caso:

@Test
fun `rota privada deve aceitar token valido`() = testApplication {
    val settings = JwtSettings(
        secret = "segredo-longo-apenas-para-testes",
        issuer = "test-suite",
        audience = "task-api",
        realm = "task-api-test",
    )

    application {
        secureTaskModule(
            repository = InMemoryTaskRepository(),
            jwtSettings = settings,
        )
    }

    val token = TestTokenFactory(settings).create(
        subject = "user-42",
        role = "EDITOR",
    )

    val response = client.get("/tasks") {
        bearerAuth(token)
    }

    assertEquals(HttpStatusCode.OK, response.status)
}

Não use token estático copiado de produção. Além do risco de segurança, ele vai expirar e tornar o teste frágil. O guia de Ktor Authentication com JWT mostra como separar emissão, validação e autorização para que cada parte seja testável.

Testes com banco: quando usar Testcontainers

Um repositório em memória não prova que:

  • a query SQL é válida;
  • o nome da coluna está correto;
  • a migration foi aplicada;
  • o índice único funciona;
  • o timezone é tratado como esperado;
  • a transação faz rollback;
  • o driver e o PostgreSQL concordam sobre tipos.

Quando esses riscos importam, suba um PostgreSQL descartável com Testcontainers:

@Testcontainers
class TaskPostgresIntegrationTest {

    companion object {
        @Container
        val postgres = PostgreSQLContainer("postgres:17-alpine")
    }

    @Test
    fun `deve persistir e consultar tarefa`() = runTest {
        val dataSource = createDataSource(
            jdbcUrl = postgres.jdbcUrl,
            username = postgres.username,
            password = postgres.password,
        )

        runMigrations(dataSource)
        val repository = PostgresTaskRepository(dataSource)

        val created = repository.create("Testar com banco real")
        val found = repository.findById(created.id)

        assertEquals(created, found)
    }
}

Depois, um número menor de testes pode conectar esse repositório real ao testApplication, comprovando o fluxo HTTP completo. Não faça todos os casos simples atravessarem o container. Validação de string vazia continua pertencendo a um teste unitário ou de rota com fake.

Use a imagem da mesma família e versão principal adotada em produção. Execute migrations reais, não SchemaUtils.create ou um schema alternativo só para teste. O guia de Testcontainers com Kotlin e PostgreSQL cobre lifecycle, reuso e integração com CI.

Como testar serviços externos

Evite chamar APIs públicas reais na suíte normal. Rede externa introduz indisponibilidade, limite de requisições, custo e dados difíceis de controlar.

Escolha uma destas estratégias:

  • fake de porta: ideal para regras que dependem de um resultado externo;
  • mock engine do Ktor Client: verifica request e simula response sem rede;
  • servidor stub local: útil para contrato HTTP, timeouts e formatos;
  • sandbox do fornecedor: reserve para poucos testes agendados ou pré-release.

Com MockEngine, um cliente externo pode receber respostas determinísticas:

val engine = MockEngine { request ->
    assertEquals("/risk/user-42", request.url.encodedPath)

    respond(
        content = """{"score":18,"approved":true}""",
        status = HttpStatusCode.OK,
        headers = headersOf(
            HttpHeaders.ContentType,
            ContentType.Application.Json.toString(),
        ),
    )
}

val httpClient = HttpClient(engine) {
    install(ContentNegotiation) { json() }
}

Teste também timeout, 429, 500, JSON incompleto e resposta lenta. O guia de Ktor Client resiliente explica como distinguir retry seguro de repetição perigosa.

Coroutines, tempo e concorrência

Evite Thread.sleep em testes Kotlin. Para regras com delay, timeout ou retry, injete relógio e política de espera quando possível e use runTest para controlar tempo virtual.

@Test
fun `servico deve repetir uma vez apos falha temporaria`() = runTest {
    val gateway = FlakyGateway(failuresBeforeSuccess = 1)
    val service = TaskSyncService(
        gateway = gateway,
        retryDelay = 1.second,
    )

    service.sync("task-1")

    assertEquals(2, gateway.attempts)
}

Em testes de streaming, WebSocket ou Server-Sent Events com Ktor, sempre use timeout e encerre o cliente depois do evento esperado. Uma coleta infinita sem cancelamento pode deixar o CI pendurado mesmo que a assertion já tenha passado.

Não presuma ordem entre coroutines independentes. Se a ordem é requisito de negócio, modele-a e teste-a. Se não é, faça assertions sem depender do scheduler local.

Organização da suíte

Uma estrutura simples ajuda a encontrar a origem das falhas:

src/test/kotlin/
  domain/
    CreateTaskUseCaseTest.kt
  routes/
    TaskRoutesTest.kt
    AuthenticationRoutesTest.kt
  persistence/
    PostgresTaskRepositoryTest.kt
  clients/
    RiskApiClientTest.kt
  fixtures/
    TestTokenFactory.kt
    TaskFixtures.kt

Use builders e fixtures para dados válidos por padrão:

fun taskRequest(
    title: String = "Tarefa de teste",
) = CreateTaskRequest(
    title = title,
)

Evite uma “fixture universal” com cinquenta parâmetros e regras ocultas. Cada helper deve reduzir ruído sem esconder o cenário. Nomes de teste em português ou inglês são aceitáveis se o projeto mantiver consistência; o comportamento precisa ficar claro no relatório do CI.

Rodando os testes no CI

Comece com o comando reproduzível localmente:

./gradlew test

Se integração estiver em uma task separada:

./gradlew test integrationTest

Uma pipeline prática executa:

  1. compilação e lint;
  2. testes unitários e de rota;
  3. testes de integração com containers;
  4. geração e publicação do relatório;
  5. build do artefato somente após a suíte verde.

No CI:

  • fixe JDK e Gradle Wrapper;
  • use cache apenas para dependências e outputs seguros;
  • não coloque credenciais reais nos testes;
  • defina timeout para jobs e casos externos;
  • publique XML/HTML mesmo quando houver falha;
  • preserve logs do container sem dados sensíveis;
  • evite paralelismo quando testes ainda compartilham banco, porta ou arquivo.

O guia de CI/CD para Kotlin ajuda a ligar essas etapas ao deploy sem transformar teste flakey em bloqueio ignorado.

Erros comuns ao testar Ktor

Testar apenas o service

A regra pode estar correta e a rota continuar quebrada. Cubra status, serialização e plugins com testApplication.

Subir servidor em porta fixa

Portas causam colisão no desenvolvimento e no CI. Use o test host, salvo quando um teste de sistema realmente precisar de processo externo.

Compartilhar repositório em memória

Um teste cria dados que outro encontra por acidente. Crie estado novo por caso ou faça limpeza explícita.

Mockar tudo

Mocks demais acoplam a suíte a chamadas internas. Use fakes para comportamentos comuns e mocks para interações específicas.

Usar banco H2 para representar PostgreSQL

Dialeto, tipos e constraints diferem. Se produção usa PostgreSQL, teste persistência crítica com PostgreSQL em container.

Verificar apenas 200 OK

Um endpoint pode responder 200 com JSON incorreto. Valide o contrato relevante e os headers.

Ignorar caminhos de erro

Autenticação inválida, conflito, timeout e payload ruim acontecem em produção. Eles merecem testes de primeira classe.

Depender de ordem global

Testes devem poder rodar isoladamente e, quando possível, em paralelo. Não nomeie métodos com números para fabricar uma sequência.

Colocar segredo no código de teste

Use valores sintéticos exclusivos da suíte. Nunca copie tokens, senhas ou chaves de ambientes reais.

Não impor timeout em stream

Uma coroutine que aguarda evento para sempre pode consumir o tempo inteiro do pipeline. Encerre coleta e conexão deliberadamente.

Checklist de uma suíte confiável

  • regras de domínio têm testes unitários rápidos;
  • rotas críticas passam por testApplication;
  • cliente de teste serializa e desserializa JSON;
  • módulos aceitam dependências substituíveis;
  • respostas 400, 401, 403, 404 e 409 são cobertas;
  • exceções inesperadas não vazam detalhes internos;
  • JWT válido, expirado e com assinatura errada são testados;
  • persistência crítica usa o mesmo banco de produção em container;
  • migrations reais rodam nos testes de integração;
  • APIs externas usam fake, MockEngine ou stub controlado;
  • testes de coroutine não dependem de Thread.sleep;
  • cada caso começa com estado isolado;
  • streams e retries possuem timeout;
  • relatório de testes é publicado no CI;
  • a suíte roda com um comando documentado localmente.

Perguntas frequentes

testApplication sobe um servidor real?

Ele executa a aplicação em um ambiente de teste do Ktor e fornece um cliente que atravessa a pipeline HTTP, sem exigir uma porta externa. É suficiente para routing, plugins, serialização e grande parte dos testes de API.

Preciso usar MockK para testar rotas?

Não. Um fake em memória costuma deixar testes de CRUD e fluxos simples mais legíveis. Use MockK quando precisar controlar falhas ou verificar uma interação específica.

Como testar JSON no Ktor?

Instale ContentNegotiation no cliente criado por createClient, envie objetos com setBody e leia respostas com response.body<T>(). Para contratos públicos, valide também status, headers e campos obrigatórios.

Devo testar PostgreSQL com H2?

Para regras puramente abstratas, um fake pode bastar. Para SQL, migrations, tipos e constraints, use PostgreSQL real via Testcontainers. H2 não reproduz todas as particularidades do PostgreSQL.

Como testar uma rota protegida por JWT?

Configure uma chave sintética de teste, gere o token durante o caso e envie com bearerAuth. Cubra token ausente, inválido, expirado, sem permissão e válido.

testApplication funciona com Koin?

Sim. Carregue módulos de teste que substituam repositórios e clients externos. Evite depender de um container global compartilhado entre casos e confirme que o override realmente foi aplicado.

Quantos testes end-to-end uma API precisa?

Poucos, concentrados nos fluxos de maior risco. A maior parte da confiança deve vir de testes unitários, de rota e de integração focados, que são mais rápidos e informam melhor a causa da falha.

Conclusão

Testar Ktor com Kotlin não exige escolher entre mocks frágeis e uma infraestrutura completa em cada caso. testApplication ocupa o meio certo: executa routing e plugins em memória, aceita requisições HTTP pelo cliente de teste e detecta defeitos reais de contrato sem depender de porta ou deploy.

Construa a suíte por responsabilidades. Teste regras no domínio, rotas com fakes, persistência com PostgreSQL descartável e integrações externas com respostas controladas. Acrescente autenticação, erros e concorrência desde o início, porque são exatamente os caminhos que tutoriais felizes costumam omitir e a produção encontra primeiro.

Para continuar, conecte este guia a Ktor Authentication com JWT, Testcontainers com PostgreSQL, OpenAPI e Swagger no Ktor e OpenTelemetry com Kotlin. Uma API bem testada, documentada e observável também é um projeto forte para demonstrar experiência nas vagas Kotlin e backend.

Fontes oficiais para continuar