โ† All guides

Mock API for Ktor & Kotlin Multiplatform โ€” HttpClient against a real URL

Ktor ships the best in-process mock in the Kotlin ecosystem: MockEngine. It's multiplatform, it swaps in with one line of DI, and for millisecond-fast unit tests it is the right tool โ€” the comparison table at the bottom says so plainly. But the mock only exists inside your Kotlin process:

The complement is a hosted mock with a real https URL: every Kotlin Multiplatform target, every device, every teammate and CI hit the same address. Every snippet below was compiled and run verbatim against the live demo project before publishing โ€” Kotlin 1.9 / Ktor 2.3.12 (CIO engine) with kotlinx-serialization on JVM 17.

1. Get an API (10 seconds, no signup)

curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
  -H 'content-type: application/json' -d '{"name":"ktor-demo"}'
# โ†’ {"id":"abc123","adminKey":"...","baseUrl":"https://mockbird.mockbird.workers.dev/m/abc123", ...}

curl -X POST https://mockbird.mockbird.workers.dev/api/projects/abc123/resources \
  -H 'content-type: application/json' -H 'x-admin-key: YOUR_KEY' \
  -d '{"name":"products","template":"products","seed":30}'

That's 30 realistic products with full CRUD, filtering, sorting, search and pagination. The create response includes a dashboard link that opens the project in a web UI. The snippets below use the public demo project so they run with zero setup.

Gradle deps used by this guide (in a KMP project these go in commonMain โ€” every snippet works from common code with the platform engine of your choice):

implementation("io.ktor:ktor-client-core:2.3.12")
implementation("io.ktor:ktor-client-cio:2.3.12")          // or darwin / okhttp / js engine
implementation("io.ktor:ktor-client-content-negotiation:2.3.12")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12")
implementation("io.ktor:ktor-client-auth:2.3.12")         // ยง7 only

2. HttpClient quickstart โ€” kotlinx-serialization, real pagination headers

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class Product(
    val id: Int,
    val name: String,
    val price: Double,
    val category: String,
    val inStock: Boolean
)

val client = HttpClient(CIO) {
    install(ContentNegotiation) {
        json(Json { ignoreUnknownKeys = true })
    }
}

const val BASE = "https://mockbird.mockbird.workers.dev/m/demo"

fun main() = runBlocking {
    val res: HttpResponse = client.get("$BASE/products") {
        parameter("page", 1)
        parameter("limit", 5)
    }
    val total = res.headers["X-Total-Count"]
    val products: List<Product> = res.body()
    println("total=$total got=${products.size}")
    products.forEach { println("${it.id}  ${it.name}  $${it.price}  ${it.category}") }
}
total=30 got=5
1  Name Problem  $952.41  garden
2  Minute Question Window Road  $959.74  office
3  Eye Part Night  $842.03  electronics
4  Water Work Fact System Book  $359.08  toys
5  System Member Fact Water Hour  $11.99  sports

X-Total-Count is a real response header, so your paging logic computes total pages from the mock exactly as it will from production. Filters, search and sorting are query params too: ?category=clothing, ?q=water, ?sortBy=price&order=desc, ?price_gte=100 โ€” full list.

3. Writes are real โ€” and persist

import io.ktor.client.call.*
import io.ktor.client.request.*
import io.ktor.http.*
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable

@Serializable
data class NewProduct(
    val name: String,
    val price: Double,
    val category: String,
    val inStock: Boolean
)

fun main() = runBlocking {
    // CREATE โ€” writes really persist (this is not response theater)
    val created = client.post("$BASE/products") {
        contentType(ContentType.Application.Json)
        setBody(NewProduct("Ktor Test Kettle", 49.99, "kitchen", true))
    }
    val product: Product = created.body()
    println("POST -> ${created.status}  id=${product.id}")

    // READ IT BACK โ€” the record is actually there
    val back: Product = client.get("$BASE/products/${product.id}").body()
    println("GET  -> ${back.name} $${back.price}")

    // CLEAN UP
    val gone = client.delete("$BASE/products/${product.id}")
    println("DELETE -> ${gone.status}")
}
POST -> 201 Created  id=31
GET  -> Ktor Test Kettle $49.99
DELETE -> 200 OK

The record you POST is really stored: the JS target sees it, the iOS target sees it, a teammate's curl sees it. That's the difference between a mock API and a mock response โ€” optimistic-update and cache-invalidation code paths behave like they will in production.

4. Sad paths: force any status, hit a real timeout

With MockEngine the timeout branch is faked by construction โ€” the request never leaves the process. Here the 3-second delay is served over the real network, so HttpTimeout genuinely fires:

import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import kotlinx.coroutines.runBlocking

fun main() = runBlocking {
    // 1) Force any status code with ?mock_status= โ€” test your error branch
    val res = client.get("$BASE/products") { parameter("mock_status", 503) }
    println("forced: ${res.status}  body=${res.bodyAsText()}")

    // 2) Real timeout: HttpTimeout at 1s vs a server that takes 3s to answer
    val impatient = HttpClient(CIO) {
        install(HttpTimeout) { requestTimeoutMillis = 1000 }
    }
    try {
        impatient.get("$BASE/products") { parameter("mock_delay", 3000) }
        println("timeout: unexpectedly succeeded")
    } catch (e: HttpRequestTimeoutException) {
        println("timeout: caught HttpRequestTimeoutException (as your app should)")
    }
    impatient.close()
}
forced: 503 Service Unavailable  body={
  "error": "simulated 503 error (mock_status)"
}
timeout: caught HttpRequestTimeoutException (as your app should)

Because the parameters ride on the URL, QA can reproduce any state by editing a query string. ?mock_status=401 for the session-expired flow, ?mock_delay=3000 to watch your loading state, both at once for slow failures. More recipes.

5. Exercise Ktor's real HttpRequestRetry plugin with injected chaos

Most Ktor apps install HttpRequestRetry. Almost none ever test it against a server that actually flakes. ?mock_chaos=0.5 makes half of all requests fail with a random 500/502/503/504/429 โ€” over the real network, with real backoff delays:

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import java.util.concurrent.atomic.AtomicInteger

val absorbed = AtomicInteger(0)   // count the failures your retry policy ate

val resilient = HttpClient(CIO) {
    install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
    install(HttpRequestRetry) {
        maxRetries = 4
        retryIf { _, response -> response.status.value >= 500 || response.status.value == 429 }
        exponentialDelay()
        modifyRequest { absorbed.incrementAndGet() }
    }
}

fun main() = runBlocking {
    // Half of all requests fail with a random 500/502/503/504/429 โ€”
    // the HttpRequestRetry plugin should absorb every one of them.
    repeat(5) { i ->
        val products: List<Product> = resilient.get("$BASE/products") {
            parameter("mock_chaos", 0.5)
            parameter("limit", 3)
        }.body()
        println("call ${i + 1}: 200 records=${products.size}")
    }
    println("failures absorbed by HttpRequestRetry: ${absorbed.get()}")
    resilient.close()
}
call 1: 200 records=3
call 2: 200 records=3
call 3: 200 records=3
call 4: 200 records=3
call 5: 200 records=3
failures absorbed by HttpRequestRetry: 6

Five clean results โ€” and the counter proves the plugin earned them: six injected failures were retried away. (With maxRetries = 4 and 50% chaos an unlucky call can still exhaust its retries โ€” that's the point: you get to see what your code does when that happens, too.) Chaos-failed writes are not applied (the server "died" before processing), so it's safe to point idempotency logic at POSTs. Add ?mock_jitter=800 for random latency, or ?mock_ratelimit=10 to rehearse 429-with-Retry-After handling (guide).

6. Pin tests to frozen snapshots โ€” empty states included

Data snapshots freeze a named copy of the whole dataset. Any request carrying X-Mockbird-Snapshot: name (or ?mock_snapshot=name) is answered read-only from that copy โ€” so parallel tests can't race each other, and the empty state is one query param away: ?mock_snapshot=empty โ†’ []. With Ktor, pinning is one defaultRequest header:

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test

/** A client whose every request is pinned to a named snapshot.
 *  Pinned reads come from the frozen copy โ€” live data never changes,
 *  so parallel tests can't race each other. */
fun pinned(snapshot: String?) = HttpClient(CIO) {
    install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
    if (snapshot != null) defaultRequest {
        header("X-Mockbird-Snapshot", snapshot)
    }
}

class SnapshotPinningTest {

    @Test
    fun `empty state renders when there is no data`() = runBlocking<Unit> {
        val products: List<Product> = pinned("empty")
            .get("$BASE/products") { parameter("limit", 50) }.body()
        assertEquals(0, products.size)          // frozen empty dataset
    }

    @Test
    fun `list renders against the full dataset`() = runBlocking<Unit> {
        val products: List<Product> = pinned(null)
            .get("$BASE/products") { parameter("limit", 50) }.body()
        assertTrue(products.size >= 30)         // live seeded data
    }
}
[INFO] Running SnapshotPinningTest
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.573 s -- in SnapshotPinningTest

Both tests run concurrently against the same project and can't interfere: one sees a frozen empty dataset, the other the live data. On your own project you'd POST /api/projects/:id/snapshots {"name":"baseline"} once, then pin each test class to the fixture it needs.

7. Wire the Auth plugin before the auth backend exists

Every project ships mock auth endpoints: any email + password returns a real signed, expiring JWT. Ktor's Auth plugin plugs straight into it โ€” loadTokens logs in once, then every request carries a Bearer token:

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.auth.*
import io.ktor.client.plugins.auth.providers.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable data class LoginRequest(val email: String, val password: String)
@Serializable data class LoginResponse(val token: String)

/** Ktor's Auth plugin wired against Mockbird's mock JWT endpoint:
 *  loadTokens logs in once; every request then carries a real signed Bearer token. */
val authed = HttpClient(CIO) {
    install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) }
    install(Auth) {
        bearer {
            loadTokens {
                val login: LoginResponse = client.post("$BASE/auth/login") {
                    contentType(ContentType.Application.Json)
                    setBody(LoginRequest("qa@example.com", "any-password"))
                }.body()
                BearerTokens(login.token, login.token)
            }
        }
    }
}

@Serializable data class Me(val user: kotlinx.serialization.json.JsonObject)

fun main() = runBlocking {
    val products: List<Product> = authed.get("$BASE/products") { parameter("limit", 2) }.body()
    println("authed request ok, got=${products.size}")
    val me: Me = authed.get("$BASE/auth/me").body()      // token round-trips
    println("me -> ${me.user}")
    authed.close()
}
authed request ok, got=2
me -> {"id":0,"email":"qa@example.com","name":"qa"}

Your whole auth plumbing โ€” storage, refreshTokens-on-401 (expiresIn: 5 at login gives you a token that dies in five seconds), logout โ€” works before the real identity provider is picked. If the project has a users resource, logins with a seeded user's email return that record as the user, and flipping the project to protected mode makes every endpoint demand the token โ€” so the 401 path is real too. Full auth guide.

8. Honest comparison

The in-process tools are genuinely good, and for fast unit tests they're usually the better choice. The split:

Ktor MockEngineMockWebServerWireMockMockbird
What it isIn-process fake engine (official Ktor artifact)Real loopback server in your test (OkHttp project)Standalone/JVM stub serverHosted mock API
Kotlin Multiplatformโœ” all targetsโœ– JVM/Android onlyโœ– JVM (or self-hosted server)โœ” it's just a URL
Works offline / zero latencyโœ”โœ”โœ”โœ– (real network)
Reachable from curl / iOS simulator / teammate / CI-against-previewโœ– in-process onlyโœ– loopbackSelf-host + expose it yourselfโœ” same URL everywhere
Stubs you must write & maintainEvery handler, by handEvery enqueued response, by handEvery stub mapping (rich DSL)None โ€” CRUD, filters, search, pagination, auth built in
Real timeouts / chaos / rate limitsโœ– nothing is realPartial โ€” throttle APIโœ” fault injection (paid Cloud for some)โœ” mock_delay / mock_chaos / mock_ratelimit
Verify "was this called?"โœ” requestHistoryโœ” takeRequest()โœ” verification DSLPartial: request inspector
Freeโœ” (library)โœ” (library)โœ” OSS (Cloud is paid)โœ” 20 projects, 10k req/project/day

Use both: keep MockEngine for unit tests with request verification, and point emulator sessions, the iOS half of your KMP app, demos and CI at a hosted URL. Already have an OpenAPI spec? POST it to us and it's a live mock.

Building an Android UI on top? The Android guide covers Retrofit/OkHttp against the same project. Backend service in Java, Node, Python, Go, Rust, Swift or C#? Same drill. Docs โ†’
โšก Skip the terminal: this link creates a live, seeded backend (products, orders, customers, reviews) in the dashboard โ€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR and mock your exact shapes.