Journey into Kotlin Testing: Coroutines, Ktor, and Android ViewModels
Context: What I’m Building
Today’s work revolved around tightening my Android/Kotlin testing muscle: writing unit tests for asynchronous flows, HTTP clients built on Ktor, custom Kotlinx serializers, and ViewModel behaviors. I wanted fast, deterministic, host‑side tests that don’t reach the network and don’t need a device. That meant:
- Keeping logic pure and small.
- Wrapping platform APIs so they’re mockable.
- Using Ktor’s
MockEngineto simulate server responses. - Using
kotlinx-coroutines-testto control virtual time. - Verifying analytics and navigation paths without real Android UI.
The goals were correctness, speed, and repeatability.
What I Learned / Architecture Decisions
- Design for testability: tiny adapters (e.g.,
BuildInfoProvider,AnalyticsManager) make platform code replaceable in tests. - Coroutines require time semantics: use
runTest,advanceTimeBy, andrunCurrentto deterministically step through timers and collectors. - Ktor
MockEngine> hand‑rolled fakes: it lets me assert URL, headers, and request body while returning controlledHttpStatusCodes and headers. - Avoid mocking core/final types: instead of mocking
String,Bitmap, orBuild, wrap them in interfaces (or use Robolectric shadows when appropriate). - Custom serializers need both directions: test
serializeanddeserializefor single and multiple values—and error branches. - RFC‑1123 headers are sneaky: parsing
Retry-Aftersupports numeric seconds and RFC‑1123 dates—tests should cover both. - Byte Buddy warnings are often benign: but sometimes indicate you’re mocking something you shouldn’t. Prefer indirection over brute force.
Key Concepts Explained
Controlling Coroutines Time in Tests
runTest { … }sets up a test scheduler.runCurrent()processes queued tasks without moving time.advanceTimeBy(millis)moves virtual time and runs scheduled delays.advanceUntilIdle()runs everything that’s currently queued.
Mental model: events in the queue vs passage of test time.
Ktor MockEngine
- Intercepts
HttpClientrequests and lets you respond with static JSON, status codes, and headers. - Great for testing error branches (
4xx/5xx) and headers likeRetry-After.
Wrapping Platform APIs
- Create small interfaces (e.g.,
BuildInfoProvider) and provide a system implementation + a test fake. - This avoids mocking final/static classes and keeps tests stable.
Serializer Strategies
- When the API returns
"value"or["value1","value2"], a customKSerializercan normalize toList<String>. - Test all branches: JSON array, JSON primitive,
null, and unexpected shapes.
Code Snippets & Scenarios (Dummy, simplified)
Note: These are illustrative snippets I wrote for learning; they are not tied to any specific product code.
1) Testing a Timer in a ViewModel
class CountdownVm(private val scope: CoroutineScope) {
val text = MutableStateFlow("0:00")
var running = false
private set
fun start(seconds: Int) {
if (running) return
running = true
scope.launch {
for (i in seconds downTo 0) {
val m = i / 60
val s = i % 60
text.value = "%d:%02d".format(m, s)
delay(1000)
}
running = false
}
}
}
Test with virtual time:
@Test fun timer_counts_down() = runTest {
val vm = CountdownVm(this)
vm.start(2)
runCurrent() // t=0
assertEquals("0:02", vm.text.value)
advanceTimeBy(1000); runCurrent() // t=1
assertEquals("0:01", vm.text.value)
advanceTimeBy(1000); runCurrent() // t=2
assertEquals("0:00", vm.text.value)
}
2) Ktor MockEngine with Retry-After
class OtpClient(private val http: HttpClient) {
suspend fun request(): Long? {
val res = http.post("/otp") { expectSuccess = false }
if (res.status.isSuccess()) return null
return res.headers["Retry-After"]?.toLongOrNull()
}
}
@Test fun otp_429_yields_retry_after() = runTest {
val engine = MockEngine { req ->
respond("rate limited", HttpStatusCode.TooManyRequests, headersOf("Retry-After", "7"))
}
val client = HttpClient(engine)
val api = OtpClient(client)
val retry = api.request()
assertEquals(7L, retry)
}
3) Serializer for String or List
object StringOrList : KSerializer<List<String>> {
override val descriptor = PrimitiveSerialDescriptor("StrOrList", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): List<String> {
val jd = decoder as? JsonDecoder ?: error("JSON only")
return when (val el = jd.decodeJsonElement()) {
is JsonPrimitive -> el.contentOrNull?.let { listOf(it) } ?: emptyList()
is JsonArray -> el.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }
else -> emptyList()
}
}
override fun serialize(encoder: Encoder, value: List<String>) {
val je = encoder as? JsonEncoder ?: error("JSON only")
if (value.size == 1) je.encodeJsonElement(JsonPrimitive(value.first()))
else je.encodeJsonElement(JsonArray(value.map(::JsonPrimitive)))
}
}
Round‑trip tests (dummy):
@Test fun single_value_serializes_as_string() {
val json = Json
val s = json.encodeToString(StringOrList, listOf("one"))
assertEquals("\"one\"", s)
val back = json.decodeFromString(StringOrList, s)
assertEquals(listOf("one"), back)
}
4) Wrapping Platform Build Info
interface BuildInfoProvider {
val brand: String
val model: String
val osName: String
val osVersion: String
}
class SystemBuildInfoProvider : BuildInfoProvider {
override val brand = android.os.Build.BRAND ?: "unknown"
override val model = android.os.Build.MODEL ?: "unknown"
override val osName = "Android"
override val osVersion = android.os.Build.VERSION.RELEASE ?: android.os.Build.VERSION.SDK_INT.toString()
}
class FakeBuildInfoProvider(
override val brand: String = "unknown",
override val model: String = "unknown",
override val osName: String = "Android",
override val osVersion: String = "DSDK"
) : BuildInfoProvider
Use FakeBuildInfoProvider in unit tests to avoid mocking Build.
What Worked, What Didn’t
Worked
runTest+advanceTimeBy+runCurrentfor predictable timers.- Ktor
MockEnginefor verifying request bodies and header‑driven behavior. - Interface wrappers for platform services (analytics/build/device info) to simplify mocking.
- Parameterized tests for serializer branches.
Didn’t (or was painful)
- Mocking final/inline/JDK classes (e.g.,
String,Bitmap, or AndroidBuild). This caused agent warnings and brittle tests. - Forgetting to set
expectSuccess = falsewhen I needed to inspect error responses and headers. - Over‑eager verification of calls before advancing the test scheduler—led to false negatives.
What I’d Do Differently
- Push more logic behind small interfaces from day one (network, build info, analytics, clock).
- Introduce a
Clockabstraction so time math (like parsingRetry-After) doesn’t depend on the system clock in tests. - Keep network DTOs simple and focused; do transformation in a separate layer that’s easier to test.
- Add contract tests around boundary behavior (e.g., malformed RFC‑1123 headers) with clear fallbacks.
References / Further Reading
- Kotlin Coroutines Test: virtual time &
runTest - Ktor Client Testing with
MockEngine - Kotlinx Serialization custom
KSerializer - Robolectric vs. host‑side testing: when to use each
- Test doubles: spies, fakes, and why wrappers beat mocking final classes
This post is part of my ongoing AI‑assisted learning log. If you’ve got tricks for taming time in coroutine tests or favorite patterns for Ktor mocking, I’d love to hear them!