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.
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
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.
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.
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.
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).
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.
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.
The in-process tools are genuinely good, and for fast unit tests they're usually the better choice. The split:
| Ktor MockEngine | MockWebServer | WireMock | Mockbird | |
|---|---|---|---|---|
| What it is | In-process fake engine (official Ktor artifact) | Real loopback server in your test (OkHttp project) | Standalone/JVM stub server | Hosted 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 | โ loopback | Self-host + expose it yourself | โ same URL everywhere |
| Stubs you must write & maintain | Every handler, by hand | Every enqueued response, by hand | Every stub mapping (rich DSL) | None โ CRUD, filters, search, pagination, auth built in |
| Real timeouts / chaos / rate limits | โ nothing is real | Partial โ throttle API | โ fault injection (paid Cloud for some) | โ mock_delay / mock_chaos / mock_ratelimit |
| Verify "was this called?" | โ requestHistory | โ takeRequest() | โ verification DSL | Partial: 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.