Engineering

Most mocking libraries do not work in commonTest, and the fix is better anyway

Dynamic proxies need a JVM. What replaces them asserts on outcomes instead of calls.

MobileByteSensei 9 min read
kmptestingarchitecture

TL;DR

  • A commonTest suite that compiled on all five targets ran on one: the mocking library is JVM-only, so ./gradlew allTests reported jvmTest PASSED and skipped nothing visibly.
  • Dynamic proxies need runtime bytecode generation. Kotlin/Native and wasm have none, by design — this is not a gap waiting to be filled.
  • Rewriting 41 tests as hand-written fakes took an afternoon and changed what they assert: 7 of them failed immediately, on behaviour that had been green for months.
  • What reading on buys: the fake's shape, the one place a mock is still the right answer, and why the rewrite found bugs rather than just restoring coverage.

The case this post is about

A shared SyncService with the obvious test:

// core/sync/src/commonMain/kotlin/SyncService.kt
class SyncService(
    private val api: Api,
    private val store: PendingStore,
    private val clock: Clock,
) {
    suspend fun flush(): FlushResult {
        val pending = store.all()
        if (pending.isEmpty()) return FlushResult.Nothing
        return when (val r = api.push(pending)) {
            is Resp.Ok     -> { store.clear(pending.map { it.id }); FlushResult.Pushed(pending.size) }
            // The bug, live for seven months: clears everything that was pending rather
            // than only what the server accepted. `coVerify { store.clear(any()) }` passed.
            is Resp.Partial-> { store.clear(pending.map { it.id }); FlushResult.Partial(r.accepted.size) }
            is Resp.Err    -> FlushResult.Failed(r.message)
        }
    }
}
// core/sync/src/commonTest/kotlin/SyncServiceTest.kt
@Test
fun flushPushesPendingItems() = runTest {
    val api = mockk<Api>()
    val store = mockk<PendingStore>(relaxed = true)
    coEvery { store.all() } returns listOf(item("a"), item("b"))
    coEvery { api.push(any()) } returns Resp.Ok

    SyncService(api, store, fixedClock).flush()

    coVerify(exactly = 1) { api.push(any()) }
    coVerify { store.clear(any()) }
}

Forty-one tests in that shape. ./gradlew :core:sync:allTests was green, and the module's coverage report said 84%.

The symptom

The first signal was not a failure. It was a line in the task list that I had read a hundred times without reading it:

$ ./gradlew :core:sync:allTests

> Task :core:sync:jvmTest
SyncServiceTest > flushPushesPendingItems PASSED
... 41 tests

> Task :core:sync:desktopTest NO-SOURCE
> Task :core:sync:iosArm64Test NO-SOURCE
> Task :core:sync:iosSimulatorArm64Test NO-SOURCE
> Task :core:sync:wasmJsTest NO-SOURCE

BUILD SUCCESSFUL in 24s

NO-SOURCE. Not "skipped", not a warning — the task had nothing to compile, so Gradle reported it as up to date and exited zero. Forty-one shared tests, one target.

When I forced the native compilation to find out why, the reason was unambiguous:

$ ./gradlew :core:sync:compileTestKotlinIosSimulatorArm64
e: Unresolved reference: mockk
   core/sync/src/commonTest/kotlin/SyncServiceTest.kt:9

The tests had never been in the native source set at all. The build file explained it:

commonTest.dependencies {
    implementation(libs.kotlin.test)
}
jvmTest.dependencies {
    implementation(libs.mockk)          // ← so commonTest cannot see it
}

Which means the file in commonTest/ referencing mockk could only ever compile for the JVM. The native and wasm test compilations excluded it, found no other sources, and reported NO-SOURCE.

Five test tasks in the graph with four reporting no-source rather than skipped, so a shared suite of forty-one tests compiled and ran on the JVM alone while the build exited zero and the coverage report read eighty-four percent

Why the obvious fix failed

The obvious fix is to move mockk into commonTest:

commonTest.dependencies {
    implementation(libs.kotlin.test)
    implementation(libs.mockk)
}
e: Could not resolve io.mockk:mockk:1.13.x
   Required by: project :core:sync (iosSimulatorArm64Test)
   No matching variant: the consumer requested native/ios_simulator_arm64

No variant exists, because there is nothing to publish. The second attempt was a multiplatform mocking library, which does resolve — and then fails differently:

Mocking is not supported on Kotlin/Native and Kotlin/Wasm targets.
Use a manual test double.

That message is the honest one, and it took me a while to accept that it is not a limitation to route around. The library is telling you the technique does not exist here.

The actual mechanism

A mocking library builds a stand-in object at runtime, and the mechanisms for doing that are platform facilities:

On the JVM, a dynamic proxy or generated bytecode. java.lang.reflect.Proxy or a bytecode library synthesises a class implementing your interface, with every method routed to an interceptor that records the call and consults a stub table.

On Kotlin/Native, there is no runtime class loading. The binary is fully linked ahead of time. There is no mechanism to introduce a new type after compilation, and kotlin.reflect is a thin subset that does not include the pieces a proxy needs.

On wasm, the same, plus no reflection surface at all.

So the test technique depends on a capability that exists on exactly one of your five targets. It is not that mocking is unsupported; it is that generating a class at runtime is unsupported, and that is what a mock is.

And there is a second-order consequence that turned out to matter more than the portability. A mock's natural assertion is verify { api.push(any()) } — a structural claim about which calls happened. A fake's natural assertion is assertEquals(2, api.pushed.size) — a behavioural claim about what the collaborator ended up holding. The platform constraint pushes you from the first to the second, and the second is the one that correlates with defects.

That is why the rewrite found bugs. coVerify { store.clear(any()) } passes whether you cleared the two items you pushed or cleared everything in the store, because any() accepts both. A fake that records what it was asked to clear cannot be satisfied by the wrong argument.

The natural assertion of a mock compared with that of a fake on the same scenario, with the mock's argument matcher accepting the wrong ids for seven months while the fake records which ids were cleared and cannot be satisfied by the wrong ones

The fix

A hand-written fake per collaborator, in commonTest, with the state the assertions need exposed as plain properties.

// core/sync/src/commonTest/kotlin/fakes/FakeApi.kt
//
// A fake, not a mock: it BEHAVES, and it records enough for a test to assert on the outcome.
// No proxy, no bytecode, so it compiles for every target the module declares.
class FakeApi : Api {

    /** Every batch this was asked to push, in order. The assertion surface. */
    val pushed = mutableListOf<List<PendingItem>>()

    /**
     * The next response, as a QUEUE rather than a single value. A single `var` makes every
     * call in a test return the same thing, which silently turns a retry test into a
     * single-attempt test — the exact shape of two of the bugs this rewrite found.
     */
    private val responses = ArrayDeque<Resp>()

    fun willReturn(vararg rs: Resp) { responses.addAll(rs) }

    override suspend fun push(items: List<PendingItem>): Resp {
        pushed += items
        // An empty queue is a TEST error, not a default. Returning Ok here would make a test
        // that performs more calls than it configured pass for the wrong reason.
        return responses.removeFirstOrNull()
            ?: error("FakeApi.push called ${pushed.size}× but only " +
                     "${pushed.size - 1} response(s) were configured")
    }
}
// core/sync/src/commonTest/kotlin/fakes/FakePendingStore.kt
class FakePendingStore(initial: List<PendingItem> = emptyList()) : PendingStore {

    private val items = initial.associateBy { it.id }.toMutableMap()

    /** Ids this was asked to clear, flattened, in order — so a test can assert WHICH ones. */
    val cleared = mutableListOf<String>()

    override suspend fun all(): List<PendingItem> = items.values.toList()

    override suspend fun clear(ids: List<String>) {
        cleared += ids
        // Clearing an id that is not present is a bug in the caller, and a fake that
        // tolerated it would hide exactly that. `remove` returning null is the signal.
        ids.forEach { id ->
            items.remove(id) ?: error("cleared id '$id' which was not pending")
        }
    }
}

The test, rewritten — same scenario, different assertions:

@Test
fun flushPushesPendingItemsAndClearsExactlyThose() = runTest {
    val store = FakePendingStore(listOf(item("a"), item("b")))
    val api = FakeApi().apply { willReturn(Resp.Ok) }

    val result = SyncService(api, store, fixedClock).flush()

    assertEquals(FlushResult.Pushed(2), result)
    assertEquals(listOf(listOf("a", "b")), api.pushed.map { b -> b.map { it.id } })
    assertEquals(listOf("a", "b"), store.cleared)      // WHICH ids, not "clear was called"
    assertEquals(emptyList(), store.all())
}

And the output it produced, the first time the rewritten suite ran on all five targets:

$ ./gradlew :core:sync:allTests

> Task :core:sync:jvmTest                   41 tests, 7 failed
> Task :core:sync:iosSimulatorArm64Test     41 tests, 7 failed
> Task :core:sync:iosArm64Test              41 tests, 7 failed
> Task :core:sync:wasmJsTest                41 tests, 7 failed
> Task :core:sync:desktopTest               41 tests, 7 failed

FAILED: flushClearsOnlyAcceptedItemsOnPartial
  expected: [a, b]
  actual:   [a, b, c]            ← Partial accepted two, the service cleared three

Seven failures, identical on every target — so not a portability problem. The partial-sync bug is the one worth naming: Resp.Partial carries the accepted ids, the service was passing pending.map { it.id } rather than r.accepted, and coVerify { store.clear(any()) } had been accepting that for seven months.

How we proved it

That the tests had only ever run on one target, which the green build actively obscured:

$ ./gradlew :core:sync:allTests --dry-run | grep -E 'Test$'
:core:sync:jvmTest SKIPPED
:core:sync:desktopTest SKIPPED
:core:sync:iosArm64Test SKIPPED
:core:sync:iosSimulatorArm64Test SKIPPED
:core:sync:wasmJsTest SKIPPED

$ ./gradlew :core:sync:allTests | grep -cE 'NO-SOURCE'
4

Five tasks in the graph, four with nothing to compile. The task list was the evidence and it had been printing every run.

That the seven failures were real behaviour and not an artifact of the rewrite. The test is to fix the production code and confirm the same seven go green without touching the fakes:

# one line: pending.map { it.id } → r.accepted
$ ./gradlew :core:sync:allTests
> 41 tests, 0 failed      × 5 targets

And the inverse, which is the half that matters: re-break the production code and confirm all five targets notice, rather than one:

$ ./gradlew :core:sync:allTests
jvmTest FAILED · desktopTest FAILED · iosArm64Test FAILED
iosSimulatorArm64Test FAILED · wasmJsTest FAILED

Five independent executions catching one shared bug. That is the property the mock-based suite could not have had at any coverage percentage.

That the fake's strictness is doing work and is not just ceremony. Both error() branches above ship with a deliberately-broken test that must fail, run before the fakes were trusted:

$ ./gradlew :core:sync:jvmTest --tests '*UnconfiguredResponse*'
FAILED: FakeApi.push called 2× but only 1 response(s) were configured

$ ./gradlew :core:sync:jvmTest --tests '*ClearsUnknownId*'
FAILED: cleared id 'zz' which was not pending

A fake that returned a default on an unconfigured call would have passed the first of those, and that is precisely the shape of the retry test that was silently only testing one attempt.

Where a mock is still right. Two of the 41 stayed as JVM-only tests with a mock, and I would not change them: both assert on a collaborator that is only called for its side effect, where there is no resulting state to observe — a crash-reporter breadcrumb and an analytics event. verify { analytics.track("sync_failed") } is a structural assertion and it is the honest one, because the behaviour under test genuinely is "a call happened". Those two live in jvmTest, with a comment saying why, and the shared behaviour they sit beside is covered by fakes in commonTest.

The limit I cannot resolve. Forty-one tests in one module is a small sample, and the 7 failures were all in two code paths — partial sync and retry — so the ratio is not a general claim about mock-based suites. What transfers is the mechanism: any() accepts the wrong argument, and a fake recording what it received does not.

The rewritten suite's first run producing seven identical failures on every one of five targets, then going green on a one-line production fix, then failing on all five again when that fix is deliberately reverted

What this generalizes to

A platform constraint occasionally improves a design by removing an option you were overusing, and this is one of those. The portability problem has a narrow fix — hand-write the double — and the narrow fix changes the assertion from which calls were made to what state resulted, which is the difference between a test that fails when you refactor and a test that fails when you break something. The general move is to notice when a technique is unavailable because of a real capability gap rather than an oversight, and to ask what the unavailable technique was letting you avoid asserting.


Keep reading

One post a week on shipping mobile software. No digests, no course pitch. Subscribe.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS