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 TypeSpeedConfidenceUse When
Unit testsFastLowerTesting business logic in isolation
Integration testsSlowerHigherTesting 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

  1. Using deprecated handleRequest API — Ktor 1.x and 2.x used handleRequest. Ktor 3.x uses testApplication with a real HTTP client. If you find old tutorials using handleRequest, ignore them.

  2. Not isolating tests — Tests that share state fail randomly. Each test should create its own data in a fresh testApplication block.

  3. Testing implementation details — Test behavior, not internals. Test “POST /api/notes returns 201” instead of “NoteRepository.create is called once”.

  4. Forgetting to install ContentNegotiation on the test client — The default test client does not serialize JSON. Always create a client with ContentNegotiation installed.

  5. 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.