6 min read

Journey into Kotlin Testing: Coroutines, Ktor, and Android ViewModels

Deep dive into writing robust unit tests for coroutines, Ktor MockEngine, custom serializers, and Android ViewModels—plus lessons learned and the tradeoffs I made.

#Android#Dev-tools#Learning-log

Table of contents

  1. Context: What I’m Building
  2. What I Learned / Architecture Decisions
  3. Key Concepts Explained
  4. Controlling Coroutines Time in Tests
  5. Ktor MockEngine
  6. Wrapping Platform APIs
  7. Serializer Strategies
  8. Code Snippets & Scenarios (Dummy, simplified)
  9. 1) Testing a Timer in a ViewModel
  10. 2) Ktor MockEngine with Retry-After
  11. 3) Serializer for String or List
  12. 4) Wrapping Platform Build Info
  13. What Worked, What Didn’t
  14. Worked
  15. Didn’t (or was painful)
  16. What I’d Do Differently
  17. References / Further Reading

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 MockEngine to simulate server responses.
  • Using kotlinx-coroutines-test to control virtual time.
  • Verifying analytics and navigation paths without real Android UI.

The goals were correctness, speed, and repeatability.


What I Learned / Architecture Decisions

  1. Design for testability: tiny adapters (e.g., BuildInfoProvider, AnalyticsManager) make platform code replaceable in tests.
  2. Coroutines require time semantics: use runTest, advanceTimeBy, and runCurrent to deterministically step through timers and collectors.
  3. Ktor MockEngine > hand‑rolled fakes: it lets me assert URL, headers, and request body while returning controlled HttpStatusCodes and headers.
  4. Avoid mocking core/final types: instead of mocking String, Bitmap, or Build, wrap them in interfaces (or use Robolectric shadows when appropriate).
  5. Custom serializers need both directions: test serialize and deserialize for single and multiple values—and error branches.
  6. RFC‑1123 headers are sneaky: parsing Retry-After supports numeric seconds and RFC‑1123 dates—tests should cover both.
  7. 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 HttpClient requests and lets you respond with static JSON, status codes, and headers.
  • Great for testing error branches (4xx/5xx) and headers like Retry-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 custom KSerializer can normalize to List<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 + runCurrent for predictable timers.
  • Ktor MockEngine for 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 Android Build). This caused agent warnings and brittle tests.
  • Forgetting to set expectSuccess = false when 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 Clock abstraction so time math (like parsing Retry-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!

Share

More to explore

Keep exploring

Previous

Compose x Flutter: Why My specified Bottom Padding Failed (and How I Fixed It)

Next

Daily Learnings: Architecture Patterns and Testing Strategies