Engineering

A Compose UI test in commonTest runs five times and fails on one

The clock, the input source and the available fonts differ per target.

MobileByteSensei 9 min read
kmptestingcompose

TL;DR

  • A runComposeUiTest asserting a truncated label read "Shopping li…" passed on four targets and failed on wasm with "Shopping lis…" — one character, because the font that laid it out was a different font.
  • runComposeUiTest compiles for every target and runs against five different text engines, input sources and clocks. The test is portable; the assertion was about a measurement.
  • Three classes of assertion need per-target treatment: measured text, anything timed, and anything driven by a gesture. Everything else is genuinely portable and most of our suite is.
  • What reading on buys: the three classes, what to assert instead of each, and the one case we moved out of commonTest on purpose.

The case this post is about

A list row whose title truncates, with the obvious test:

// commonMain/kotlin/feed/ListRow.kt
@Composable
fun ListRow(title: String) {
    Text(
        text = title,
        maxLines = 1,
        overflow = TextOverflow.Ellipsis,
        modifier = Modifier.width(120.dp).testTag(TestTags.ROW_TITLE),
    )
}
// commonTest/kotlin/feed/ListRowTest.kt
@OptIn(ExperimentalTestApi::class)
@Test
fun longTitleTruncates() = runComposeUiTest {
    setContent { ListRow("Shopping list for the weekend") }
    onNodeWithTag(TestTags.ROW_TITLE).assertTextEquals("Shopping li…")
}

Written on a JVM run, green there, and committed.

The symptom

$ ./gradlew :feature:feed:allTests

> Task :feature:feed:jvmTest                  PASSED
> Task :feature:feed:androidUnitTest          PASSED
> Task :feature:feed:iosSimulatorArm64Test    PASSED
> Task :feature:feed:desktopTest              PASSED
> Task :feature:feed:wasmJsTest               FAILED
    ListRowTest.longTitleTruncates
      expected: "Shopping li…"
      actual:   "Shopping lis…"

One character. Not a layout bug, not a Compose difference — the available fonts differ, so the glyph advances differ, so a different number of characters fits in 120 dp.

And a second failure in the same run, which was the more useful one:

> Task :feature:feed:wasmJsTest FAILED
    ListRowTest.tappingTheRowNavigates
      Expected exactly '1' node matching (TestTag = 'detail-screen'), but found '0'

A tap that worked on four targets and not on the fifth, for an unrelated reason: performClick dispatches a synthetic pointer event, and the row's clickable had an onLongClick as well, so the gesture detector waits for the long-press timeout before resolving a tap — and on a single-threaded target the test's virtual clock and the gesture detector's timeout did not advance together.

The resolved font and the resulting truncation point on each target, with four agreeing at eleven characters by coincidence of metrics and the fifth fitting twelve, so nothing is wrong with any of the numbers

Why the obvious fix failed

The first fix was to assert the prefix rather than the whole string:

onNodeWithTag(TestTags.ROW_TITLE).assertTextContains("Shopping", substring = true)

Which passes everywhere and asserts almost nothing — it would pass if the text were not truncated at all, which is the behaviour under test. The test name says longTitleTruncates and the assertion no longer checks that it does.

The second fix was to pin the font so every target lays out identically:

setContent {
    CompositionLocalProvider(LocalFontFamilyResolver provides fixedResolver(Res.font.Inter)) {
        ListRow("Shopping list for the weekend")
    }
}

Closer, and it failed differently: the bundled font loads asynchronously on wasm, so the first composition laid out with a fallback and the assertion ran against that. Adding a wait for the font made the test pass and made it a font-loading test, which is a thing worth having and is not this.

The third fix was @IgnoreWasm, and the question that stopped it was the same one as always: four targets agree, so is the fifth wrong about the code? Here the answer was no — wasm was right that thirteen characters fit, and the test was wrong to have an opinion about how many.

The actual mechanism

commonTest is a source set, so one @Test becomes five independent executions — and runComposeUiTest wires each one to that target's own Compose integration. Three things differ in ways that reach an assertion.

Text measurement differs because the font stack differs. The JVM and desktop resolve through Skia with the host's fonts. Android uses the platform's font config. iOS resolves through CoreText. wasm has only what the page has loaded, and a bundled font arrives asynchronously. Same maxLines, same width, different glyph advances, different truncation point.

The clock and the gesture detector are separately driven. runComposeUiTest gives you a test clock you advance with mainClock. A gesture detector's timeouts — long-press, double-tap — read that clock on most targets and, on a single-threaded target, interleave with the event loop in a way that makes "advance the clock then assert" insufficient without an explicit waitForIdle.

Input synthesis differs. performClick injects a pointer-down/up pair. How quickly, and whether anything else can run between them, is per target — which matters only when a gesture detector is distinguishing a tap from something else.

So the portable question is what the composable did and the non-portable one is what it measured. An assertion about semantics — this node exists, this node is enabled, this toggle is on, this text was passed in — is portable. An assertion about a laid-out result is a per-target fact.

That split also explains why most of the suite was fine: of 214 Compose tests in the module, 3 asserted on measured text and 2 on gesture timing. The portability problem was 5 tests, and @IgnoreWasm would have hidden a real finding in one of them.

Which kinds of assertion are portable and which are per-target facts, with semantics about what a composable did travelling everywhere and a laid-out measurement belonging to one platform

The fix

Assert the semantics, not the measurement — and where a measurement genuinely is the subject, make the test say which target it is about.

// commonMain/kotlin/feed/ListRow.kt
//
// The composable now reports the FACT the test needs through semantics, so the test can assert
// behaviour without asserting a glyph count. This is a real API addition rather than a test
// affordance: a screen reader benefits from knowing the text is truncated, and that is the
// same information.
@Composable
fun ListRow(title: String) {
    var didOverflow by remember(title) { mutableStateOf(false) }

    Text(
        text = title,
        maxLines = 1,
        overflow = TextOverflow.Ellipsis,
        onTextLayout = { didOverflow = it.hasVisualOverflow },
        modifier = Modifier
            .width(120.dp)
            .testTag(TestTags.ROW_TITLE)
            .semantics {
                // The assertion target: WHETHER it truncated, not where.
                isTruncated = didOverflow
                // And the full text, so a reader (and a test) can get the untruncated value.
                text = AnnotatedString(title)
            },
    )
}
// commonTest — portable, and still checking the behaviour the name claims
@OptIn(ExperimentalTestApi::class)
@Test
fun longTitleTruncates() = runComposeUiTest {
    setContent { ListRow("Shopping list for the weekend") }

    // The behaviour: it overflowed. True on every target, and false if maxLines or the
    // overflow mode regresses — which the substring assertion could not detect.
    onNodeWithTag(TestTags.ROW_TITLE)
        .assert(SemanticsMatcher.expectValue(SemanticsProperties.IsTruncated, true))
}

@OptIn(ExperimentalTestApi::class)
@Test
fun shortTitleDoesNotTruncate() = runComposeUiTest {
    setContent { ListRow("Milk") }
    onNodeWithTag(TestTags.ROW_TITLE)
        .assert(SemanticsMatcher.expectValue(SemanticsProperties.IsTruncated, false))
}

And the gesture test, with the timing made explicit rather than assumed:

@OptIn(ExperimentalTestApi::class)
@Test
fun tappingTheRowNavigates() = runComposeUiTest {
    // autoAdvance OFF, so the clock moves only where the test says. With it on, the implicit
    // advance raced the gesture detector's long-press timeout on the single-threaded target —
    // which presented as a tap that did nothing, 0 of 50 times passing.
    mainClock.autoAdvance = false
    setContent { NavRoot() }

    onNodeWithTag(TestTags.ROW).performClick()

    // Past the long-press threshold, deliberately: the detector must resolve the gesture as a
    // tap, and that resolution is time-dependent because onLongClick exists.
    mainClock.advanceTimeBy(ViewConfiguration.LongPressTimeoutMillis + 50)
    waitForIdle()

    onNodeWithTag(TestTags.DETAIL_SCREEN).assertExists()
}

And the one test that moved out of commonTest on purpose:

// androidUnitTest/kotlin/feed/ListRowTruncationTest.kt
//
// The exact truncation point IS the subject here — a designer asked for "two words then an
// ellipsis at this width on Android". That is a per-platform fact, so it lives in a
// per-platform source set rather than being made portable by weakening it.
@Test
fun titleTruncatesAfterTwoWordsOnAndroid() = runComposeUiTest {
    setContent { ListRow("Shopping list for the weekend") }
    onNodeWithTag(TestTags.ROW_TITLE).assertTextEquals("Shopping li…")
}

And the output it produced:

$ ./gradlew :feature:feed:allTests

> Task :feature:feed:jvmTest                  214 tests, PASSED
> Task :feature:feed:androidUnitTest          215 tests, PASSED   (+ the truncation test)
> Task :feature:feed:iosSimulatorArm64Test    214 tests, PASSED
> Task :feature:feed:desktopTest              214 tests, PASSED
> Task :feature:feed:wasmJsTest               214 tests, PASSED

BUILD SUCCESSFUL

How we proved it

That the font stack was the cause — measured rather than inferred, because "text differs" has several possible explanations:

  target      resolved font for the default family   chars before the ellipsis
  jvm         Inter (host)                                      11
  android     Roboto (platform config)                          11
  ios         SF Pro (CoreText)                                 11
  desktop     Inter (host)                                      11
  wasmJs      sans-serif fallback (font not yet loaded)         12

Eleven on four targets by coincidence of metrics, twelve on the fifth. Nothing is wrong with any of these numbers.

That the substring assertion was vacuous. The check, because it is the fix most likely to ship:

  assertion                              with truncation   with maxLines removed
  assertTextEquals("Shopping li…")            pass              FAIL
  assertTextContains("Shopping", true)        pass              pass        ← detects nothing
  assert(IsTruncated, true)                   pass              FAIL

The middle row passes in both states, so it would not have caught a regression that removed the truncation entirely.

That the gesture failure was the clock and not the click. Separated by turning off the implicit advance, which is the only way to tell:

  configuration                                      passes
  autoAdvance on (default), wasm                      0 / 50
  autoAdvance on, jvm                                50 / 50
  autoAdvance off + advanceTimeBy(longPress + 50)    50 / 50 on all five

And the control that proves it is the long-press and not the tap: removing onLongClick from the row makes the original test pass on wasm, 50 of 50, because there is no longer a gesture to disambiguate.

That the portability problem was five tests and not the suite. The number that kept the fix proportionate:

  Compose tests in the module                      214
  asserting on measured text                         3
  asserting on gesture timing                        2
  portable as written                              209

@IgnoreWasm on the file would have disabled 214 tests to accommodate 5 — and one of the 5 was a real finding.

That one of the five was a real bug. This is the reason the ignore-annotation was the wrong instinct:

  wasmJsTest  ListRowTest.subtitleFitsOnOneLine  FAILED
    expected IsTruncated false, actual true

The subtitle genuinely did overflow on wasm at the default font size, and it overflowed on Android too at a font scale above 1.3 — which nothing had tested. The wasm failure was the first report of a defect present on two platforms.

What I cannot tell you is whether IsTruncated is stable across Compose versions. It is a semantics property we now depend on in 3 tests, and a semantics property is a less-established API surface than assertTextEquals. If it changes, those 3 tests break loudly, which is the acceptable failure mode.

And an honest limit. The per-platform truncation test in androidUnitTest is now the only assertion of the designer's actual requirement, and it is checked on one target. The requirement genuinely is per-platform, so that is correct — and it does mean a change to the shared composable can alter iOS truncation with no test noticing. We accepted that; a golden-image test per target would cover it and costs more than the requirement is worth here.

Three measurements: a substring assertion that passes whether or not truncation happens, a gesture failure isolated to the clock rather than the click, and five of two hundred and fourteen tests needing per-target treatment

What this generalizes to

A test that compiles for every target runs against every target's own implementation of the thing it is testing, and the question is whether the assertion is about behaviour or about a result that implementation produced. Behaviour is portable; a measurement is a per-platform fact, and the honest options are to assert the behaviour instead or to put the measurement in a per-platform source set. The option to avoid is weakening the assertion until it passes everywhere, because a test that cannot fail on the target it was written for has stopped being evidence on all five.


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