Pantrix UI

Testing a screen

Why the test of a screen written in Compose Multiplatform changes from machine to machine, how a component is found by its automation tag, what is put in place of the platform's services, and how the same tags reach device automation (Maestro). The problem is not Pantrix's own: it is seen the same way in every Compose Multiplatform project that uses automation tags, a theme, the clipboard, a file picker or the clock.

Everything said about Compose was read in the sources of Compose Multiplatform 1.12.2 and of compose ui-test 1.12.2, or seen in a test that runs in this repository; what is said about Maestro was seen with Maestro 2.10.0 on Android emulators (an API 34 tablet, a Pixel 7 Pro on API 33, a Pixel 9 Pro Fold on API 35), in the iPhone 18 Pro Max simulator (iOS 27.0) and in Maestro's Chromium. Which was seen where is written in sections 7 and 8.

1. The problem

Symptom

Cause

2. The solution: five pieces

PieceWhat it gives
1Build the tag by the component's rule. The test writes the component's name and key; the function the component uses builds the tagThe rule is in one place; the key is written the same as in the screen's code
2A fixed theme. The platform, the language, dark mode, the density, the font scale, motion and the operating system are the test's choiceThe test sees the same screen on every machine
3Test doubles. The clipboard, the file picker, analytics, haptic feedback and the clock are the test's ownThe machine is not touched; what the screen says is recorded
4Assertions. The diagnostics' findings, the name, the touch target, the shortcut, the textAccessibility and the platform's rules are tested in one line
5Device automation. The same tags reach Android and iOSA Maestro flow too looks for the key in the screen's code; it does not break when the language changes

Without the first piece the others are brittle: every tag written by hand is a copy of the rule. Without the second and the third, a test is bound to the machine.

3. With Pantrix

The test module ui/testing is published as com.pantrixui:testing; its package is com.pantrixui.testing. It depends only on :ui:foundation and on compose ui-test: components are given by their names, as text. Three additions in foundation come with it: pantrixTestTagOf, LocalPantrixClock and LocalFilePicker.

3.1. Setting up

kotlin {
    sourceSets {
        commonTest.dependencies {
            implementation("com.pantrixui:testing:0.1.0-alpha03")
            implementation(kotlin("test"))
        }
        // runComposeUiTest really draws on the desktop: the test JVM needs this machine's Skiko.
        jvmTest.dependencies { implementation(compose.desktop.currentOs) }
    }
}

3.2. Finding a component

APIWhat it does
hasPantrixTag(component, tagKey?, scope?)A SemanticsMatcher: the tag the component carries with this key and in this scope
onPantrixNode(component, tagKey?, scope?)That single node; if there is none, or more than one, what is done to it fails
onAllPantrixNodes(component, tagKey?, scope?)All of them, in the tree's order
pantrixTags()The Pantrix tags the screen carries, in the tree's order
pantrixTestTagOf(component, tagKey, scope) (foundation)The tag's text, without the screen being set up
onPantrixNode("Button", "Save draft").performClick()            // Button_save_draft
onPantrixNode("Button", "pay", scope = "Checkout.Payment")      // Checkout.Payment.Button_pay
onAllPantrixNodes("Button", scope = "Checkout").assertCountEquals(3)
onPantrixNode("Segment", "newest", scope = "SegmentButton_sort") // a control's part

3.3. A fixed theme

PantrixTestTheme shows a screen the same on every machine. The defaults:

ParameterDefaultWhy
platformPlatformType.AndroidThe touch targets, the focus rings and the platform's ways are fixed
systemThe platform's; Windows on the desktop and the webWhat shortcuts start with (⌘ or Ctrl) does not depend on the machine
darkfalse
localePantrixLocale.EnglishThe text's direction comes with it
densityPantrixDensity.Comfortable
fontScale1fOnly the text grows; the screen's pixels stay the same
windownull: the window the test drawsIf it is given, inside PantrixWindowFrame: folds and layers follow it
motionReducedNothing is caught halfway; a snackbar comes in and goes out in one frame
findingsnull: diagnostics on, findings to the logIf it is given, the findings are gathered there (3.5)
analytics, hapticsNoOpGiven a recorder, what the screen says is kept (3.4)
clipboardThe theme's own TestClipboardThe machine's clipboard is never written
filePickerThe theme's own picker, which never answersNo window of the system opens
clocknull: the device's clockEvery test that shows a date or a time should give its own TestClock
@OptIn(ExperimentalTestApi::class)
@Test
fun pay_is_told_and_the_screen_has_no_findings() = runComposeUiTest {
    val findings = PantrixFindings()
    val analytics = RecordingAnalytics()
    setContent { PantrixTestTheme(findings = findings, analytics = analytics) { CheckoutScreen() } }
    onPantrixNode("Button", "pay").performClick()
    assertEquals(listOf("click"), analytics.eventsOf("Button").map { it.action })
    assertNoFindings(findings)
}

On every platform: onEachPlatform(content, check) sets up the screen on the four platforms in turn, in a single run of the test. On the web a run is a promise the test gives back, so no one waits for runs started from a loop; nothing that fails in them fails the test.

onEachPlatform({ platform -> PantrixTestTheme(platform = platform) { SaveButton() } }) { platform ->
    onPantrixNode("Button", "save").assertTouchTargetOf(platform)
}

3.4. Test doubles

DoubleWhat it keeps, how it answers
TestClipboard(traits)What the screen copied, in order. text() and texts() read as the platform reads: on Android the first item of the ClipData, on the desktop the Transferable's text, on iOS plain text, on the web text/plain. On the web, reading waits for the browser (suspend). traits is told to the screen as the platform's traits: ClipboardTraits(announcedBySystem = true) is Android 13, ClipboardTraits(writable = false) a page served over plain http. The platform clipboard that Compose's text field asks (nativeClipboard) is the test's too: on the desktop its own AWT clipboard that keeps the last copy, on iOS a pasteboard with its own name, apart from the general pasteboard
RecordingAnalytics()The events the components report, in order: events, eventsOf("Button")
RecordingHaptics()The haptic feedback played, in order: played
TestFilePicker(files)Answers every pick with files: only with the types the picker shows, and with the first if a single file was asked for; if none fits, it does not answer, like a user who closes the picker. requests keeps what each pick asked for
TestClock(now, utcOffset)Shows the moment the test set up; it moves only when the test moves it (advanceBy, now = …). If utcOffset is not given, it is UTC's time
TestFileSaver(result)Keeps the files the screen saved, in order (files), and answers every save with result: saved, if it is not given. The theme always gives its own, and no window for saving opens; a test that reads what was saved gives its own inside the theme, through LocalFileSaver

The doubles listed, but TestFileSaver, are PantrixTestTheme's parameters. They can be given without the theme too, from foundation's own places:

// The whole screen's picker: rememberFilePicker() and PantrixFileDrop's default take this.
CompositionLocalProvider(LocalFilePicker provides picker) { OfferScreen() }

// The screen's saver: rememberFileSaver() gives this. It is given inside PantrixTestTheme.
CompositionLocalProvider(LocalFileSaver provides saver) { ThemeBuilderScreen() }

// The screen's clock: rememberToday(), the time field's picker, the elastic date picker and the charts' moments read this.
CompositionLocalProvider(LocalPantrixClock provides PantrixClock(clock) { 3.hours }) { BookingScreen() }

3.5. Assertions

APIWhat it asserts
assertNoFindings(findings)The diagnostics found nothing: a control with no name, an unreadable colour, a squeezed touch target, one tag on two nodes. It first moves the test's clock past a second and a half, then looks; if it fails, it names each finding
assertHasName(name)The name the screen reader gives the node: its content description, or else its text
assertTouchTargetOf(platform)The area a finger can press is as large as the platform asks: 48dp on Android, 44pt on iOS, 32dp with a mouse on the desktop and the web. What is measured is the region that takes the press, not what is drawn
performShortcut(shortcut, system)Presses the shortcut as that system's keyboard does: with Command on Apple's systems, with Control on the others. That the screen too is written for that system is said by PantrixTestTheme(system = …)
readText()The text the node shows: what a text field holds, or else its texts and the texts of what it merges, line by line

Pixel and colour helpers are not in the module: looking for a colour in pixels depends on the component's drawing, and stays only in Pantrix's own tests.

3.6. Screenshots

There is no helper in the module for screenshot tests, and the module is not bound to Roborazzi: on which platform, with which tool and where to record is the app's choice. Pantrix's own setup is in the Roborazzi guide (in Turkish). Its summary for an app:

3.7. Device automation: Maestro

Pantrix's tags reach the device's accessibility tree: on Android as resource-id (the outermost theme, and the root of every modal or popup layer, turns testTagsAsResourceId on), on iOS as accessibilityIdentifier (Compose converts testTag itself). A Maestro flow finds a component with id:, by the key in the screen's code; the flow does not break when the app opens in another language.

A window the app opens itself, a Popup or a Dialog of its own, is outside these roots on Android: the root of what it shows carries Modifier.pantrixAutomationRoot() itself. On iOS, the desktop and the web this changes nothing.

Popup(onDismissRequest = onClose) {
    PantrixModalLayer {
        PantrixSurface(modifier = Modifier.pantrixTransientsRoot().pantrixAutomationRoot()) {
            PantrixButton(text = stringResource(Res.string.close), onClick = onClose, tagKey = "close_layer")
        }
    }
}
appId: ${APP_ID}
---
- launchApp:
    stopApp: true
- tapOn:
    id: "Button_browse"
- tapOn:
    id: "TextField_search"
- inputText: "PantrixSheet"
- tapOn:
    id: "Card_sheet"

The demo's flows are under app/maestro/; every page of the catalogue has a flow:

The demo's MaestroFlowsTest (JVM) checks that the flows keep up with the catalogue: every page has a flow in its section's folder, the flow opens the page by its link and visits each of the page's examples in order. A page or an example added without its flow fails the test, with no device.

HowCommand
By handOn Android's debug build maestro test -e APP_ID=com.pantrixui.cmp.demo.debug -e LINK_SCHEME=pantrixui-debug --exclude-tags fold app/maestro; on iOS the bundle identifier, which carries the team identifier (com.pantrixui.cmp.demo.PantrixUI<TEAM_ID>), and no LINK_SCHEME
Android, Gradle./gradlew :app:androidApp:maestroTest: installs the debug build on that device alone and runs the flows
iOS, Gradle./gradlew :app:shared:maestroIosTest: builds the app with Xcode for the open simulator, installs it and runs the flows
One section./gradlew :app:androidApp:maestroTest -Pmaestro.flows=flows/form
Smoke test./gradlew :app:shared:maestroIosTest -Pmaestro.include=smoke

When to use which: a screen's behaviour, accessibility and platform rules are tested in common tests (runComposeUiTest), on five targets. Maestro shows, on a real device, that a real app was installed and that the tags reach the system; it takes in the keyboard, the system bars and the navigation too.

4. Without Pantrix

What follows are the shortest forms that can be copied into a project without the library; Pantrix's code (ui/testing/src/) does the same with tests and edge cases.

4.1. Building the tag in one place (commonMain and commonTest)

// commonMain: the component builds its tag with this.
fun testTagOf(component: String, key: String, scope: String? = null): String {
    val slug = key.lowercase().replace(Regex("[^a-z0-9]+"), "_").trim('_').take(32).trimEnd('_')
    return listOfNotNull(scope, "${component}_$slug").joinToString(".")
}

// commonTest: the test calls the same.
fun SemanticsNodeInteractionsProvider.onComponent(component: String, key: String, scope: String? = null) =
    onNode(hasTestTag(testTagOf(component, key, scope)))

4.2. A fixed theme and the test's clipboard (commonTest)

class KeptClipboard : Clipboard {
    val entries = mutableListOf<ClipEntry?>()
    override suspend fun getClipEntry(): ClipEntry? = entries.lastOrNull()
    override suspend fun setClipEntry(clipEntry: ClipEntry?) { entries += clipEntry }
}

@Composable
fun FixedScreen(clipboard: Clipboard = remember { KeptClipboard() }, content: @Composable () -> Unit) {
    val drawn = LocalDensity.current
    CompositionLocalProvider(
        LocalDensity provides Density(drawn.density, fontScale = 1f),
        LocalLayoutDirection provides LayoutDirection.Ltr,
        LocalClipboard provides clipboard,
        content = content
    )
}

The theme's own defaults too (the language, dark mode, motion, the operating system) must be given openly in the same way; which they are is read in the theme's parameters.

4.3. Opening the tags to android's automation (androidMain)

// Once at the root; since a Dialog or a Popup is a separate window, at its root too.
Modifier.semantics { testTagsAsResourceId = true }

It is not needed on iOS: Compose gives testTag as accessibilityIdentifier.

5. What the helper does not do

6. Testing

The module's own tests are mostly in common code and run on five targets (the JVM, the iOS simulator, JS, Wasm; Android's in a separate tree): finding, the theme, the doubles and the assertions. The platform's clipboard on the desktop and on iOS is tried by their own tests. Android's host test has no manifest: it shows that the module's manifest declares the activity; when the activity is taken out of the manifest, it fails with "Unable to resolve activity".

Pantrix's own tests use the module too. The TestTheme of the components' tests is a thin wrapper over PantrixTestTheme: the colours, the motion and the diagnostics come from a theme inside it. Events and haptic feedback are kept with RecordingAnalytics and RecordingHaptics, and the desktop's clipboard tests use TestClipboard. Foundation's and the components' Android host tests have no manifest of their own: the module's manifest declares the activity.

Each behaviour was broken once, and the test was seen to turn red (section 8). The clock is read with two offsets: this machine is at UTC+3, and a test written with one offset would pass on a component that reads the device's clock too.

7. Pitfalls

8. Verification

Tests

WhatWhere
Finding: the key and its cutting, the scope chain, finding with no key, the parts, the screen's tags and the merged treeui/testing/src/commonTest/.../PantrixTagsTest.kt (JVM, iOS, JS, Wasm)
The theme: the defaults, what is given, Windows on the desktop and the web, the window and keeping the state, the clipboard and a text field that takes the focus over it, the picker, the clock, the diagnostics, the finding that waits, every platformPantrixTestThemeTest
The doubles: analytics, haptic feedback, the picker's answers, the clock, the clipboard (hard texts included) and an unwritable clipboardTestDoublesTest
The assertions: the name, the text, the touch target, the shortcutPantrixAssertionsTest
The platform's clipboard on the desktop: it keeps the last copy, empties when cleared, and every test's clipboard is its ownui/testing/src/jvmTest/.../TestClipboardOnTheDesktopTest.kt
The platform's clipboard on iOS: the same, and that the general pasteboard is never writtenui/testing/src/iosTest/.../TestClipboardOnIosTest.kt
Android: a test with no manifest, the ClipData and its label, the pickerui/testing/src/androidHostTest/.../TestingOnAndroidTest.kt
The tag's textui/foundation/src/commonTest/.../testing/TestTagTest.kt
The screen's clock and its readersPantrixClockTest, TimeTextFieldTest, ElasticDatePickerTest, ChartScreenClockTest
The screen's pickerLocalFilePickerTest, FilesOnAndroidTest

Behaviours broken by hand

Looked at by hand

Not verified

9. Where it is in this repository

10. What is left open

In docs/OPEN_ITEMS.md: device automation on the web, and the text field menu opened with the test's clipboard on Android.