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

6 min read
#kotlin #android #testing

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:

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

Mental model: events in the queue vs passage of test time.

Ktor MockEngine

Wrapping Platform APIs

Serializer Strategies


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

Didn’t (or was painful)


What I’d Do Differently


References / Further Reading


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!

About the Author

Aniket Indulkar is an Android Engineer based in London with a Master's in Artificial Intelligence. He writes about AI, ML, Android development, and his continuous learning journey.

Connect on LinkedIn →