A REST API without tests is a ticking time bomb. You change one route, and three others break. You add a new feature, and authentication stops working.
In this tutorial, you will write tests for your Ktor application. You will test authentication flows, CRUD operations, WebSocket endpoints, and input validation — all using Ktor’s built-in test engine.
Test Setup
Ktor provides a test engine that runs your application in memory. No real HTTP server starts. This makes tests fast and isolated.
Dependencies
dependencies {
testImplementation("io.ktor:ktor-server-test-host:$ktorVersion")
testImplementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
testImplementation("io.ktor:ktor-client-websockets:$ktorVersion")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testImplementation("org.junit.jupiter:junit-jupiter:5.12.2")
}
tasks.test {
useJUnitPlatform()
}
ktor-server-test-host provides testApplication. ktor-client-content-negotiation lets the test client send and receive JSON. ktor-client-websockets enables WebSocket testing.
The testApplication Pattern
Every Ktor test follows this pattern:
@Test
fun `health check returns OK`() = testApplication {
application { module() }
val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
}
testApplication creates an in-memory server. You call application { module() } to load your application. Then you use the built-in client to send requests.
JSON Test Client
Most endpoints exchange JSON. Create a helper that configures the test client:
private fun ApplicationTestBuilder.jsonClient() = createClient {
install(ContentNegotiation) { json() }
}
Use it in every test:
@Test
fun `create note returns 201`() = testApplication {
application { module() }
val client = jsonClient()
val response = client.post("/api/notes") {
contentType(ContentType.Application.Json)
setBody(CreateNoteRequest("Test Note", "Content"))
}
assertEquals(HttpStatusCode.Created, response.status)
}
Testing Authentication
Authentication tests verify the complete flow: register, login, access protected routes, refresh tokens, and logout.
Registration Helper
Create a helper to reduce repetition:
private suspend fun register(
client: HttpClient,
name: String,
email: String,
password: String = "password123"
): TokenResponse {
return client.post("/api/auth/register") {
contentType(ContentType.Application.Json)
setBody(RegisterRequest(name, email, password))
}.body()
}
Complete Auth Flow Test
@Test
fun `complete auth flow - register login refresh logout`() = testApplication {
application { module() }
val client = jsonClient()
// 1. Register
val registerTokens = register(client, "Sam", "sam-flow@example.com")
assertNotNull(registerTokens.accessToken)
assertNotNull(registerTokens.refreshToken)
// 2. Login with same credentials
val loginResponse = client.post("/api/auth/login") {
contentType(ContentType.Application.Json)
setBody(LoginRequest("sam-flow@example.com", "password123"))
}
assertEquals(HttpStatusCode.OK, loginResponse.status)
val loginTokens = loginResponse.body<TokenResponse>()
assertNotNull(loginTokens.accessToken)
assertNotNull(loginTokens.refreshToken)
// 3. Access profile with token
val profileResponse = client.get("/api/auth/me") {
bearerAuth(loginTokens.accessToken)
}
assertEquals(HttpStatusCode.OK, profileResponse.status)
val profile = profileResponse.body<ProfileResponse>()
assertEquals("Sam", profile.name)
assertEquals("user", profile.role)
// 4. Refresh token
val refreshResponse = client.post("/api/auth/refresh") {
contentType(ContentType.Application.Json)
setBody(RefreshTokenRequest(loginTokens.refreshToken))
}
assertEquals(HttpStatusCode.OK, refreshResponse.status)
val newTokens = refreshResponse.body<TokenResponse>()
assertNotNull(newTokens.accessToken)
// 5. Old refresh token should be revoked (rotation)
val reuse = client.post("/api/auth/refresh") {
contentType(ContentType.Application.Json)
setBody(RefreshTokenRequest(loginTokens.refreshToken))
}
assertEquals(HttpStatusCode.Unauthorized, reuse.status)
// 6. Logout
val logoutResponse = client.post("/api/auth/logout") {
bearerAuth(newTokens.accessToken)
}
assertEquals(HttpStatusCode.OK, logoutResponse.status)
// 7. Refresh token should not work after logout
val afterLogout = client.post("/api/auth/refresh") {
contentType(ContentType.Application.Json)
setBody(RefreshTokenRequest(newTokens.refreshToken))
}
assertEquals(HttpStatusCode.Unauthorized, afterLogout.status)
}
This single test verifies seven critical behaviors. Each step depends on the previous one. If registration breaks, the entire flow fails — and you know immediately.
Validation Error Tests
Test that invalid input returns proper error codes:
@Test
fun `registration validation errors`() = testApplication {
application { module() }
val client = jsonClient()
// Short name
val shortName = client.post("/api/auth/register") {
contentType(ContentType.Application.Json)
setBody(RegisterRequest("A", "valid@example.com", "password123"))
}
assertEquals(HttpStatusCode.BadRequest, shortName.status)
// Invalid email
val badEmail = client.post("/api/auth/register") {
contentType(ContentType.Application.Json)
setBody(RegisterRequest("Alex", "invalid-email", "password123"))
}
assertEquals(HttpStatusCode.BadRequest, badEmail.status)
// Short password
val shortPass = client.post("/api/auth/register") {
contentType(ContentType.Application.Json)
setBody(RegisterRequest("Alex", "alex@example.com", "pass1"))
}
assertEquals(HttpStatusCode.BadRequest, shortPass.status)
// No digit in password
val noDigit = client.post("/api/auth/register") {
contentType(ContentType.Application.Json)
setBody(RegisterRequest("Alex", "alex2@example.com", "passwordonly"))
}
assertEquals(HttpStatusCode.BadRequest, noDigit.status)
}
Protected Route Tests
@Test
fun `protected route requires valid token`() = testApplication {
application { module() }
val client = jsonClient()
// No token
val noToken = client.get("/api/auth/me")
assertEquals(HttpStatusCode.Unauthorized, noToken.status)
// Invalid token
val badToken = client.get("/api/auth/me") {
bearerAuth("not-a-real-jwt-token")
}
assertEquals(HttpStatusCode.Unauthorized, badToken.status)
}
Always test both cases — missing token and invalid token. They might return different status codes if your auth is misconfigured.
Testing CRUD Operations
Create, Read, Update, Delete
@Test
fun `create read update delete note`() = testApplication {
application { module() }
val client = jsonClient()
// Create
val createResponse = client.post("/api/notes") {
contentType(ContentType.Application.Json)
setBody(CreateNoteRequest("Test Note", "Test content", tags = listOf("kotlin")))
}
assertEquals(HttpStatusCode.Created, createResponse.status)
val created = createResponse.body<NoteResponse>()
assertEquals("Test Note", created.title)
assertEquals(1, created.tags.size)
// Read
val readResponse = client.get("/api/notes/${created.id}")
assertEquals(HttpStatusCode.OK, readResponse.status)
val read = readResponse.body<NoteResponse>()
assertEquals("Test Note", read.title)
// Update
val updateResponse = client.put("/api/notes/${created.id}") {
contentType(ContentType.Application.Json)
setBody(UpdateNoteRequest(title = "Updated Note"))
}
assertEquals(HttpStatusCode.OK, updateResponse.status)
val updated = updateResponse.body<NoteResponse>()
assertEquals("Updated Note", updated.title)
// Delete
val deleteResponse = client.delete("/api/notes/${created.id}")
assertEquals(HttpStatusCode.NoContent, deleteResponse.status)
// Verify deleted
val notFound = client.get("/api/notes/${created.id}")
assertEquals(HttpStatusCode.NotFound, notFound.status)
}
Pagination Tests
@Test
fun `list notes with pagination`() = testApplication {
application { module() }
val client = jsonClient()
// Create multiple notes
repeat(5) { i ->
client.post("/api/notes") {
contentType(ContentType.Application.Json)
setBody(CreateNoteRequest("Pagination Note $i", "Content $i"))
}
}
// Get first page
val page1 = client.get("/api/notes?page=1&size=2")
assertEquals(HttpStatusCode.OK, page1.status)
val notes1 = page1.body<List<NoteResponse>>()
assertEquals(2, notes1.size)
// Get second page
val page2 = client.get("/api/notes?page=2&size=2")
assertEquals(HttpStatusCode.OK, page2.status)
val notes2 = page2.body<List<NoteResponse>>()
assertEquals(2, notes2.size)
}
Filter Tests
@Test
fun `filter notes by tag`() = testApplication {
application { module() }
val client = jsonClient()
// Create notes with different tags
client.post("/api/notes") {
contentType(ContentType.Application.Json)
setBody(CreateNoteRequest("Kotlin Note", "Content", tags = listOf("kotlin")))
}
client.post("/api/notes") {
contentType(ContentType.Application.Json)
setBody(CreateNoteRequest("Ktor Note", "Content", tags = listOf("ktor")))
}
// Filter by tag
val filtered = client.get("/api/notes?tag=kotlin")
assertEquals(HttpStatusCode.OK, filtered.status)
val notes = filtered.body<List<NoteResponse>>()
assertTrue(notes.all { "kotlin" in it.tags })
}
Validation Tests
@Test
fun `create note with blank title returns 400`() = testApplication {
application { module() }
val client = jsonClient()
val response = client.post("/api/notes") {
contentType(ContentType.Application.Json)
setBody(CreateNoteRequest("", "Content"))
}
assertEquals(HttpStatusCode.BadRequest, response.status)
}
Testing WebSocket Endpoints
WebSocket tests use the WebSockets plugin on the test client:
@Test
fun `join room and receive join message`() = testApplication {
application { module() }
val client = createClient {
install(WebSockets)
}
client.webSocket("/ws/chat/lobby?username=Alex") {
val frame = incoming.receive() as Frame.Text
val text = frame.readText()
assertTrue(text.contains("Alex joined the room"))
}
}
Send and Receive Messages
@Test
fun `send and receive message in room`() = testApplication {
application { module() }
val client = createClient {
install(WebSockets)
}
client.webSocket("/ws/chat/room1?username=Sam") {
// Receive join message
incoming.receive()
// Send a message
send("Hello from test!")
// Receive the message back (broadcast to self)
val frame = incoming.receive() as Frame.Text
val text = frame.readText()
assertTrue(text.contains("Hello from test!"))
assertTrue(text.contains("Sam"))
}
}
The WebSocket test client works like a real WebSocket client. You send frames, receive frames, and assert on their content.
Test Isolation
Each testApplication block creates a fresh application instance. Tests use an in-memory H2 database, so data does not leak between tests.
This is why we set up H2 for testing back in Tutorial #6. H2 starts fresh every time the application starts. No cleanup needed.
Test Execution Order
Tests should not depend on each other. Each test creates its own data and verifies its own results. If test A creates a user, test B should not assume that user exists.
// Good - each test is independent
@Test
fun `register new user`() = testApplication {
application { module() }
val client = jsonClient()
register(client, "Alex", "alex-test1@example.com")
// verify...
}
@Test
fun `login existing user`() = testApplication {
application { module() }
val client = jsonClient()
register(client, "Sam", "sam-test2@example.com") // Create user first
// login and verify...
}
Unit Tests vs Integration Tests
The tests above are integration tests. They test the full stack: routes, services, repositories, and database.
For unit tests, test services in isolation:
@Test
fun `note service returns null for missing note`() {
val fakeRepository = object : NoteRepository() {
override suspend fun findById(id: Int): NoteResponse? = null
}
val service = NoteService(fakeRepository)
runBlocking {
val result = service.getNoteById(999)
assertNull(result)
}
}
When to use which:
| Test Type | Speed | Confidence | Use When |
|---|---|---|---|
| Unit tests | Fast | Lower | Testing business logic in isolation |
| Integration tests | Slower | Higher | Testing the full request/response cycle |
For most Ktor applications, integration tests with testApplication give you the best balance of speed and confidence.
Testing with DI Overrides
In Tutorial #18, we added Koin for dependency injection. You can override dependencies in tests to swap real implementations with fakes:
@Test
fun `test with overridden repository`() = testApplication {
application {
install(Koin) {
modules(module {
single { UserRepository() }
single { NoteRepository() }
single { RefreshTokenRepository() }
single { AuthService(get(), get()) }
single { NoteService(get()) }
})
}
configureDatabase()
configureSerialization()
configureAuthentication()
configureStatusPages()
configureRouting()
}
val client = jsonClient()
val response = client.get("/api/notes")
assertEquals(HttpStatusCode.OK, response.status)
}
This pattern is especially useful when you want to test routes without a real database. Replace NoteRepository with a fake that returns predefined data, and your tests become fast and predictable.
When to Use DI Overrides
- Testing error handling: make the repository throw exceptions
- Testing edge cases: return empty lists, null values, large datasets
- Performance testing: remove database overhead from route tests
For most tests, using the real application with H2 is simpler and gives higher confidence. Use DI overrides when you need precise control over what a dependency returns.
Organizing Test Files
Structure your tests by feature:
src/test/kotlin/com/kemalcodes/
├── AuthTest.kt ← Authentication flow tests
├── NoteTest.kt ← CRUD and pagination tests
├── WebSocketTest.kt ← WebSocket endpoint tests
└── HealthTest.kt ← Health check and basic tests
Each test file focuses on one area. When a test fails, the file name tells you what broke.
Naming Conventions
Use backtick-quoted test names that describe the behavior:
// Good — describes what happens
@Test
fun `create note with blank title returns 400`() = ...
@Test
fun `protected route requires valid token`() = ...
// Bad — too vague
@Test
fun `test create note`() = ...
@Test
fun `test auth`() = ...
Good test names serve as documentation. When a test fails in CI, the name tells you exactly what stopped working.
Running Tests
# Run all tests
./gradlew test
# Run a specific test class
./gradlew test --tests "com.kemalcodes.AuthTest"
# Run with verbose output
./gradlew test --info
Test reports are generated at build/reports/tests/test/index.html.
Code Coverage
Add the JaCoCo plugin to track how much of your code is covered by tests:
plugins {
kotlin("jvm") version "2.3.0"
jacoco
}
tasks.jacocoTestReport {
reports {
html.required.set(true)
}
}
Run coverage:
./gradlew test jacocoTestReport
Open build/reports/jacoco/test/html/index.html to see the report.
Aim for 80%+ coverage on routes and services. 100% coverage is not always practical — focus on critical paths like authentication and data validation.
Testing Checklist
For every endpoint, test:
- Happy path — valid input, expected response
- Authentication — missing token, invalid token, expired token
- Validation — blank fields, too long, wrong types
- Not found — accessing nonexistent resources
- Edge cases — empty lists, pagination boundaries, special characters
Common Mistakes
Using deprecated
handleRequestAPI — Ktor 1.x and 2.x usedhandleRequest. Ktor 3.x usestestApplicationwith a real HTTP client. If you find old tutorials usinghandleRequest, ignore them.Not isolating tests — Tests that share state fail randomly. Each test should create its own data in a fresh
testApplicationblock.Testing implementation details — Test behavior, not internals. Test “POST /api/notes returns 201” instead of “NoteRepository.create is called once”.
Forgetting to install ContentNegotiation on the test client — The default test client does not serialize JSON. Always create a client with
ContentNegotiationinstalled.Not testing error responses — Happy path tests catch obvious bugs. Validation and error tests catch the bugs that reach production.
Source Code
You can find the source code for this tutorial on GitHub:
github.com/kemalcodes/ktor-tutorial — Branch: tutorial-19-testing
What’s Next?
Your application is tested and ready for production. In the next tutorial, you will learn Dockerizing Your Ktor Application — creating a Dockerfile, multi-stage builds, and running with Docker Compose.
Related Articles
- Ktor Tutorial #18: Dependency Injection — DI with Koin
- Ktor Tutorial #12: Registration and Login — Auth endpoints we tested
- Ktor Tutorial #7: CRUD Operations — CRUD endpoints we tested
- Ktor Tutorial #15: WebSockets — WebSocket endpoints we tested