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.
TL;DR
- A
runComposeUiTestasserting 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. runComposeUiTestcompiles 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
commonTeston 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.

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.

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.

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.
Related
- One
@Testin commonTest runs five times and fails on one — the non-UI version of the same split, where the divergence is a standard library. - The test that only failed on wasm was measuring the environment — the clock half of this, where the failing target was right.
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".