Proving It Works: Testing Ktor Apps


A web framework that’s hard to test pushes you toward not testing. Ktor went the other way: testApplication spins up your entire app in memory (no real socket, no port, no network latency) and hands you a client to call it. Tests run in milliseconds and read like the real thing.

The setup

Add the test host (the project generator included it) and a JUnit runner:

testImplementation("io.ktor:ktor-server-test-host")
testImplementation(kotlin("test"))

The simplest possible test:

import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.*

class TaskApiTest {
    @Test
    fun `lists tasks`() = testApplication {
        val response = client.get("/tasks")
        assertEquals(HttpStatusCode.OK, response.status)
    }
}

testApplication { } builds the app — it loads your application.yaml and runs your module automatically, just like production startup — and gives you a preconfigured client. No server is actually listening on a port; the client talks to the app directly through Ktor’s plumbing. That’s why it’s fast enough to run hundreds of these.

Testing JSON round-trips

The default client doesn’t know about JSON. To send and receive typed bodies, make one with content negotiation installed — the exact same plugin as the server:

import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*

@Test
fun `creates a task`() = testApplication {
    val client = createClient {
        install(ContentNegotiation) { json() }
    }

    val response = client.post("/tasks") {
        contentType(ContentType.Application.Json)
        setBody(NewTask("Write tests"))
    }

    assertEquals(HttpStatusCode.Created, response.status)
    val created = response.body<Task>()
    assertEquals("Write tests", created.title)
    assertFalse(created.done)
}

createClient { } customizes the test client; setBody(NewTask(...)) and response.body<Task>() serialize and parse with the same machinery your real clients use. You’re testing the actual JSON contract, not a mock of it.

Testing failure paths

The error handling you built deserves tests too. Assert that bad input and missing resources come back the way you promised:

@Test
fun `unknown id returns 404`() = testApplication {
    val response = client.get("/tasks/99999")
    assertEquals(HttpStatusCode.NotFound, response.status)
}

@Test
fun `blank title is rejected`() = testApplication {
    val client = createClient { install(ContentNegotiation) { json() } }
    val response = client.post("/tasks") {
        contentType(ContentType.Application.Json)
        setBody(NewTask(""))
    }
    assertEquals(HttpStatusCode.BadRequest, response.status)
}

The status codes you so carefully chose are now pinned down by tests — change one accidentally and a test goes red.

Testing authenticated routes

For protected routes, log in first, grab the token, then send it on the real request — exactly what a client does:

@Test
fun `protected route needs a token`() = testApplication {
    val client = createClient { install(ContentNegotiation) { json() } }

    // No token → rejected
    assertEquals(HttpStatusCode.Unauthorized, client.get("/tasks").status)

    // Log in, then call with the bearer token
    val token = client.post("/login") {
        contentType(ContentType.Application.Json)
        setBody(LoginRequest("alice", "secret"))
    }.body<Map<String, String>>()["token"]

    val ok = client.get("/tasks") {
        header(HttpHeaders.Authorization, "Bearer $token")
    }
    assertEquals(HttpStatusCode.OK, ok.status)
}

That’s an end-to-end auth test — the same login-then-call flow your real clients follow, verified in milliseconds.

Swapping in fakes

Here’s where the structure work pays off. Because routes take a TaskService (which takes a repository), a test can wire the app with a fake instead of a real database, configuring the app explicitly rather than letting it load the production setup:

@Test
fun `uses a fake repository`() = testApplication {
    application {
        configureSerialization()
        configureStatusPages()
        configureRouting(TaskService(FakeTaskRepository()))
    }
    // ... assertions against a clean, in-memory fake ...
}

application { } lets you configure the app by hand for a test — call only the pieces you want, with test doubles swapped in. No database to spin up, no shared state between tests, full control. The dependency injection from earlier wasn’t ceremony; this is what it bought you.

Final thoughts

testApplication erases the usual excuse that web tests are slow and fiddly: there’s no port, no network, no flakiness, and the client speaks the same JSON your real clients do. Test the contract that matters: status codes, JSON shapes, the auth flow, the error paths. And lean on the seams you built — passing services and repositories in is what lets a test run against a fake in memory instead of Postgres on disk. Well-structured code and easy testing turn out to be the same thing.

One step left: deployment — packaging this into something you can actually ship to production.

Comments