KMP Integration Testing with Ktor and Coroutines

KMP Integration Testing with Ktor and Coroutines

Integration tests in Kotlin Multiplatform verify that your components work together — not just in isolation. The most common integration point is your API layer: the code that talks to a server using Ktor. This guide covers how to integration-test a Ktor-based client across all KMP targets.

Why Integration Tests Matter in KMP

Unit tests verify individual functions with mocked dependencies. Integration tests verify that your networking code, serialization, error handling, and retry logic all work together correctly.

In KMP, there's an additional concern: Ktor's behavior can vary between its engine implementations. The CIO engine (JVM), the Darwin engine (iOS), and the Js engine each have different concurrency characteristics. Integration tests that use Ktor's MockEngine catch serialization bugs, header issues, and response parsing failures that would only appear at runtime.

Setting Up Ktor for Testing

Add Ktor with mock engine support:

// shared/build.gradle.kts
kotlin {
    sourceSets {
        val commonMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-core:2.3.7")
                implementation("io.ktor:ktor-client-content-negotiation:2.3.7")
                implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.7")
                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
            }
        }
        val commonTest by getting {
            dependencies {
                implementation(kotlin("test"))
                implementation("io.ktor:ktor-client-mock:2.3.7")
                implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
            }
        }
        val androidMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-android:2.3.7")
            }
        }
        val iosMain by getting {
            dependencies {
                implementation("io.ktor:ktor-client-darwin:2.3.7")
            }
        }
    }
}

The Production API Client

A typical KMP API client:

// commonMain
@Serializable
data class User(val id: String, val name: String, val email: String)

@Serializable
data class ApiError(val code: String, val message: String)

class UserApiClient(private val httpClient: HttpClient, private val baseUrl: String) {
    suspend fun getUser(id: String): User {
        return httpClient.get("$baseUrl/users/$id").body()
    }

    suspend fun createUser(name: String, email: String): User {
        return httpClient.post("$baseUrl/users") {
            contentType(ContentType.Application.Json)
            setBody(mapOf("name" to name, "email" to email))
        }.body()
    }

    suspend fun listUsers(page: Int = 1, limit: Int = 20): List<User> {
        return httpClient.get("$baseUrl/users") {
            parameter("page", page)
            parameter("limit", limit)
        }.body()
    }
}

Writing Integration Tests with MockEngine

Ktor's MockEngine intercepts HTTP requests and returns predefined responses without making real network calls:

// commonTest
import io.ktor.client.*
import io.ktor.client.engine.mock.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import kotlin.test.*

class UserApiClientTest {
    private fun createClient(handler: MockRequestHandler): UserApiClient {
        val mockEngine = MockEngine { request ->
            handler(request)
        }
        val httpClient = HttpClient(mockEngine) {
            install(ContentNegotiation) {
                json(Json { ignoreUnknownKeys = true })
            }
        }
        return UserApiClient(httpClient, "https://api.example.com")
    }

    @Test
    fun `fetches user by id`() = runTest {
        val client = createClient { request ->
            assertEquals("/users/user-123", request.url.encodedPath)
            respond(
                content = """{"id":"user-123","name":"Alice","email":"alice@example.com"}""",
                status = HttpStatusCode.OK,
                headers = headersOf(HttpHeaders.ContentType, "application/json")
            )
        }

        val user = client.getUser("user-123")
        assertEquals("user-123", user.id)
        assertEquals("Alice", user.name)
        assertEquals("alice@example.com", user.email)
    }

    @Test
    fun `passes pagination parameters`() = runTest {
        var capturedRequest: HttpRequestData? = null

        val client = createClient { request ->
            capturedRequest = request
            respond(
                content = "[]",
                status = HttpStatusCode.OK,
                headers = headersOf(HttpHeaders.ContentType, "application/json")
            )
        }

        client.listUsers(page = 3, limit = 50)

        assertNotNull(capturedRequest)
        assertEquals("3", capturedRequest!!.url.parameters["page"])
        assertEquals("50", capturedRequest!!.url.parameters["limit"])
    }

    @Test
    fun `sends correct content type on create`() = runTest {
        val client = createClient { request ->
            assertEquals(HttpMethod.Post, request.method)
            assertTrue(request.headers[HttpHeaders.ContentType]?.contains("application/json") == true)
            respond(
                content = """{"id":"new-id","name":"Bob","email":"bob@example.com"}""",
                status = HttpStatusCode.Created,
                headers = headersOf(HttpHeaders.ContentType, "application/json")
            )
        }

        val user = client.createUser("Bob", "bob@example.com")
        assertEquals("new-id", user.id)
    }
}

Testing Error Handling

Your API client needs to handle HTTP errors gracefully:

// commonMain — updated client with error handling
class UserApiClient(private val httpClient: HttpClient, private val baseUrl: String) {
    suspend fun getUser(id: String): Result<User> = runCatching {
        val response = httpClient.get("$baseUrl/users/$id")
        when (response.status) {
            HttpStatusCode.OK -> response.body<User>()
            HttpStatusCode.NotFound -> throw UserNotFoundException(id)
            else -> throw ApiException(response.status.value, response.bodyAsText())
        }
    }
}

class UserNotFoundException(val id: String) : Exception("User $id not found")
class ApiException(val statusCode: Int, val body: String) : Exception("API error $statusCode: $body")

Test both success and error paths:

@Test
fun `returns failure on 404`() = runTest {
    val client = createClient { _ ->
        respond(
            content = """{"code":"NOT_FOUND","message":"User not found"}""",
            status = HttpStatusCode.NotFound,
            headers = headersOf(HttpHeaders.ContentType, "application/json")
        )
    }

    val result = client.getUser("missing-id")

    assertTrue(result.isFailure)
    val exception = result.exceptionOrNull()
    assertIs<UserNotFoundException>(exception)
    assertEquals("missing-id", (exception as UserNotFoundException).id)
}

@Test
fun `returns failure on 500`() = runTest {
    val client = createClient { _ ->
        respond(
            content = "Internal Server Error",
            status = HttpStatusCode.InternalServerError
        )
    }

    val result = client.getUser("user-123")

    assertTrue(result.isFailure)
    val exception = result.exceptionOrNull()
    assertIs<ApiException>(exception)
    assertEquals(500, (exception as ApiException).statusCode)
}

Testing Retry Logic

Many production clients include retry with backoff. Test this without sleeping:

// commonMain
class RetryingUserApiClient(
    private val httpClient: HttpClient,
    private val baseUrl: String,
    private val maxRetries: Int = 3,
    private val retryDelayMs: Long = 1_000L
) {
    suspend fun getUser(id: String): User {
        var lastException: Exception? = null
        repeat(maxRetries) { attempt ->
            try {
                return httpClient.get("$baseUrl/users/$id").body()
            } catch (e: Exception) {
                lastException = e
                if (attempt < maxRetries - 1) {
                    delay(retryDelayMs * (attempt + 1)) // exponential backoff
                }
            }
        }
        throw lastException!!
    }
}
// commonTest
@Test
fun `retries on network failure and succeeds`() = runTest {
    var callCount = 0

    val mockEngine = MockEngine { _ ->
        callCount++
        if (callCount < 3) {
            throw IOException("Network error")
        }
        respond(
            content = """{"id":"u1","name":"Alice","email":"alice@example.com"}""",
            status = HttpStatusCode.OK,
            headers = headersOf(HttpHeaders.ContentType, "application/json")
        )
    }

    val httpClient = HttpClient(mockEngine) {
        install(ContentNegotiation) { json() }
    }
    val client = RetryingUserApiClient(
        httpClient = httpClient,
        baseUrl = "https://api.example.com",
        maxRetries = 3,
        retryDelayMs = 1_000L
    )

    val user = client.getUser("u1")

    assertEquals(3, callCount)
    assertEquals("Alice", user.name)
    // runTest advances virtual time — the 1s + 2s delays don't actually wait
}

Testing Authentication Headers

@Test
fun `includes auth token in request`() = runTest {
    var capturedAuth: String? = null

    val client = createClient { request ->
        capturedAuth = request.headers[HttpHeaders.Authorization]
        respond(
            content = """{"id":"u1","name":"Alice","email":"alice@example.com"}""",
            status = HttpStatusCode.OK,
            headers = headersOf(HttpHeaders.ContentType, "application/json")
        )
    }

    client.setAuthToken("Bearer test-token-abc")
    client.getUser("u1")

    assertEquals("Bearer test-token-abc", capturedAuth)
}

Multiple Request Sequencing

Test multi-step flows with a request queue:

@Test
fun `refreshes token and retries on 401`() = runTest {
    val responses = ArrayDeque<MockRequestHandler>()
    responses.add { _ ->
        respond(content = "Unauthorized", status = HttpStatusCode.Unauthorized)
    }
    responses.add { _ ->
        respond(
            content = """{"token":"new-token"}""",
            status = HttpStatusCode.OK,
            headers = headersOf(HttpHeaders.ContentType, "application/json")
        )
    }
    responses.add { _ ->
        respond(
            content = """{"id":"u1","name":"Alice","email":"alice@example.com"}""",
            status = HttpStatusCode.OK,
            headers = headersOf(HttpHeaders.ContentType, "application/json")
        )
    }

    val client = createClient { responses.removeFirst().invoke(it) }
    val user = client.getUser("u1")

    assertEquals("Alice", user.name)
    assertTrue(responses.isEmpty()) // All 3 requests were made
}

Running Tests on All Targets

# JVM (fast, good for development)
./gradlew :shared:jvmTest

<span class="hljs-comment"># iOS simulator (macOS required)
./gradlew :shared:iosSimulatorArm64Test

<span class="hljs-comment"># Android (emulator or device)
./gradlew :shared:connectedAndroidTest

<span class="hljs-comment"># All targets
./gradlew :shared:allTests

Beyond MockEngine: Real Integration Tests

MockEngine tests your client logic, but they don't test against a real server. For that, consider:

  1. Docker-based tests: Spin up your backend in CI, run against it with real HTTP
  2. WireMock / MockServer: HTTP-level mocking that catches more serialization edge cases
  3. HelpMeTest: Continuous end-to-end testing against your live API — catches production regressions 24/7

The combination of MockEngine tests (fast, local) and continuous integration tests (real server, ongoing) gives comprehensive coverage of your API layer.

Summary

  • Ktor's MockEngine works in commonTest — the same tests run on JVM, iOS, and Android
  • runTest from kotlinx-coroutines-test handles suspend functions and virtual time
  • Capture requests in the mock handler to verify URLs, headers, and parameters
  • Test the full error handling path — 404s, 500s, and network failures
  • Retry logic is testable without real delays using runTest's time control
  • Supplement with real integration tests for end-to-end validation

Read more