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