mobile-accessibility-test-ids
Skill15 starsChanged 22 days agoApache-2.0View on GitHub
Use when adding or fixing stable UI-test locators and accessibility labels in a Kotlin Multiplatform or native mobile app - Compose testTag, testTagsAsResourceId, SwiftUI accessibilityIdentifier versus accessibilityLabel, contentDescription, Appium (UiAutomator2, XCUITest), Maestro or XCUITest not finding an element, flaky XPath or text-based selectors, or QA asking for test IDs.
In this repository34 other files it gives its agents.
- AGENTS.mdAGENTS.md
- android-to-kmp-migrationSkill
- compose-design-system-componentSkill
- compose-multiplatform-localizationSkill
- compose-multiplatform-navigationSkill
- compose-multiplatform-screenSkill
- figma-to-mobile-uiSkill
- kmp-api-endpointSkill
- kmp-build-checkSkill
- kmp-build-failure-triageSkill
- kmp-ci-github-actionsSkill
- kmp-code-reviewSkill
- kmp-crash-triageSkill
- kmp-data-layerSkill
- kmp-dev-machine-cleanupSkill
- kmp-expect-actualSkill
- kmp-feature-moduleSkill
- kmp-flaky-test-triageSkill
- kmp-gradle-version-catalogSkill
- kmp-koin-diSkill
- kmp-module-healthSkill
- kmp-room-databaseSkill
- kmp-secure-storageSkill
- kmp-swiftui-interopSkill
- kmp-test-coverage-backfillSkill
- kmp-testing-patternsSkill
- kmp-xcframework-distributionSkill
- mobile-bug-fixSkill
- mobile-feature-implementSkill
- mobile-feature-planSkill
- mobile-performance-fixSkill
- mobile-platform-paritySkill
- mobile-releaseSkill
- mobile-security-reviewSkill
The file
We split this skill into the rules it gives an agent
9 rules. About 0 are common, 5 are distinctive, and 4 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
4
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 13
Every interactive element carries two different things:
- Very specific: line 38
Find the existing ID convention: Search for testTag(, TestTags, accessibilityIdentifier( and AccessibilityID.
- Very specific: line 41
Find where IDs live: Look for <Screen>TestTags objects next to the screens, and a Swift enum namespace in the app or in a package.
- Distinctive: line 61
Do not tag decorative images, dividers, spacers or layout-only containers.
- Very specific: line 81
Use the same string on Android and iOS.
- Distinctive: line 154
Never inline a raw ID string at a call site.
- Very specific: line 161
Android UiAutomator, Appium (UiAutomator2) and Maestro: read the accessibility tree, not the Compose tree.
- Distinctive: line 192
Before Compose Multiplatform 1.8.0, the iOS accessibility tree synced only while an accessibility service ran.
- Distinctive: line 259
Every new interactive element, screen root, dialog action, error, empty state and retry has an ID.
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Mobile accessibility test IDs
Every interactive element carries two different things:
- a locator: a stable, never-translated ID that tests use. This is
Modifier.testTagin Compose andaccessibilityIdentifierin SwiftUI and UIKit. - a spoken label: localised prose that TalkBack and VoiceOver read aloud. This is
contentDescriptionin Compose andaccessibilityLabelin SwiftUI and UIKit.
They are never the same string. IDs live in one namespaced object per screen, shared with the tests, and they follow one naming scheme on both platforms.
When to use
- Building or reviewing a screen that QA automation, end-to-end tests or Compose UI tests will drive.
- Appium, Maestro, UiAutomator or XCUITest cannot find a Compose or SwiftUI element.
- Selectors are flaky: XPath by position, matching on visible text, list rows by index.
- Someone put an ID into
contentDescriptionoraccessibilityLabelto make a test pass.
When not to use
- A full accessibility audit (Dynamic Type, contrast, focus order, VoiceOver rotors): see the external accessibility skills under Related skills.
- Writing unit tests for ViewModels: use
kmp-testing-patterns.
Step 0 - Read the project first
- Find the existing ID convention. Search for
testTag(,TestTags,accessibilityIdentifier(andAccessibilityID. If the project already has a scheme (for example dot notation such aslogin.email), keep it. Mixing schemes breaks shared test helpers. - Find where IDs live. Look for
<Screen>TestTagsobjects next to the screens, and a Swiftenumnamespace in the app or in a package. - Find the test tools. Appium suites (look for
capabilities,UiAutomator2,XCUITest),.maestro/flows,*UITeststargets, and ComposerunComposeUiTest/createComposeRuletests. The tool decides how an ID must be exposed (see Workflow step 4). - Find the Android host root. The
setContent {}call in theActivity, plus everyDialog,ModalBottomSheetorPopup. That is wheretestTagsAsResourceIdbelongs. - Check the UI layer. Compose Multiplatform on both platforms, Compose on Android with SwiftUI on iOS, or fully native.
Workflow
Pick the elements to tag. Tag every element a test acts on or waits for:
- buttons, links and tab items;
- text fields, toggles and pickers;
- list-row tap targets;
- dialog and sheet actions (confirm and cancel);
- the screen root, so a test can assert "we are on screen X";
- error banners, empty states, retry buttons, loading indicators.
Do not tag decorative images, dividers, spacers or layout-only containers. Over-tagging makes page sources noisy and invites accidental duplicates.
Name the IDs:
<screen>_<element>_<role>, in snake_case and English-only. They are never shown to users and never translated.Role suffix Example _screennotes_list_screen_buttonnotes_list_add_button_inputnote_editor_title_input_toggle,_link,_tabsettings_sync_toggle,settings_privacy_link_row_<stableId>notes_list_row_42_dialog,_dialog_confirm_button,_dialog_cancel_buttonnotes_list_delete_dialog_confirm_button_error,_empty_state,_loadingnotes_list_empty_state- An ID is unique on its screen. Give global surfaces (an app-wide error dialog) a global name
such as
app_error_dialog. - Dynamic rows use a stable model ID, never the list index. An insert, a sort or a new page shifts indices, and the test then taps the wrong row without any error.
- Use the same string on Android and iOS. A Compose Multiplatform
testTagbecomes the iOSaccessibilityIdentifier, so one Maestro or Appium flow can drive both platforms.
- An ID is unique on its screen. Give global surfaces (an app-wide error dialog) a global name
such as
Declare the IDs once per screen.
// commonMain, next to NotesListScreen.kt object NotesListTestTags { const val SCREEN = "notes_list_screen" const val ADD_BUTTON = "notes_list_add_button" const val EMPTY_STATE = "notes_list_empty_state" const val DELETE_DIALOG = "notes_list_delete_dialog" const val DELETE_DIALOG_CONFIRM_BUTTON = "notes_list_delete_dialog_confirm_button" const val DELETE_DIALOG_CANCEL_BUTTON = "notes_list_delete_dialog_cancel_button" private const val ROW_PREFIX = "notes_list_row_" fun row(noteId: String): String = ROW_PREFIX + noteId }@Composable fun NotesList(state: NotesUiState, onAction: (NotesAction) -> Unit, modifier: Modifier = Modifier) { Box(modifier.fillMaxSize().testTag(NotesListTestTags.SCREEN)) { LazyColumn { items(state.notes, key = { it.id }) { note -> // stable key AND stable tag NoteRow( note = note, onClick = { onAction(NotesAction.Open(note.id)) }, // the tag goes on the node that receives the click, not on an inner Text modifier = Modifier.testTag(NotesListTestTags.row(note.id)), ) } } FloatingActionButton( onClick = { onAction(NotesAction.Add) }, modifier = Modifier.align(Alignment.BottomEnd).testTag(NotesListTestTags.ADD_BUTTON), ) { // contentDescription is the spoken label: localised prose, not the ID Icon(painterResource(Res.drawable.ic_add), contentDescription = stringResource(Res.string.add_note)) } } }SwiftUI screens get a namespaced
enum. Put it in a package (public) or in a file that belongs to both the app target and the UI-test target, so tests use the same constants:public enum AccessibilityID { public enum NotesList { public static let screen = "notes_list_screen" public static let addButton = "notes_list_add_button" public static func row(_ noteID: String) -> String { "notes_list_row_\(noteID)" } } } extension View { func accessibilityID(_ id: String) -> some View { accessibilityIdentifier(id) } /// A card or row whose wrapper carries the ID while its children stay individually queryable. func accessibilityContainerID(_ id: String) -> some View { accessibilityElement(children: .contain).accessibilityIdentifier(id) } } // Call sites: never an inline string literal Button("Add note", action: onAdd).accessibilityID(AccessibilityID.NotesList.addButton) ForEach(notes) { note in NoteRow(note: note).accessibilityContainerID(AccessibilityID.NotesList.row(note.id)) }Never inline a raw ID string at a call site. The constant is what keeps a rename safe across the app and its tests.
Expose the IDs to the tools.
- Compose tests (
runComposeUiTest,createComposeRule) seetestTagdirectly throughonNodeWithTag(...). Nothing else is needed. Android UiAutomator, Appium (UiAutomator2) and Maestro read the accessibility tree, not the Compose tree. A
testTagappears there as aresource-idonly when an ancestor setstestTagsAsResourceId = true. Set it once, high in the tree, at the Activity root:// androidMain - host Activity setContent { Box(Modifier.fillMaxSize().semantics { testTagsAsResourceId = true }) { App() } }testTagsAsResourceIdis an Android API of Jetpack Compose UI. Use it fromandroidMain. If your Compose version reports it as experimental, add@OptIn(ExperimentalComposeUiApi::class). Content in aDialog,ModalBottomSheetorPopupis hosted in a separate window, and the Activity root's flag may not reach it. When that content is declared incommonMain, add a one-lineexpect/actualmodifier: the Androidactualsets the flag, the iOSactualreturnsthis. Apply it at the dialog's content root.// commonMain expect fun Modifier.exposeTestTagsAsResourceIds(): Modifier // androidMain actual fun Modifier.exposeTestTagsAsResourceIds(): Modifier = semantics { testTagsAsResourceId = true } // iosMain actual fun Modifier.exposeTestTagsAsResourceIds(): Modifier = this- iOS with Compose Multiplatform: the official docs state that
testTagmaps to the nativeaccessibilityIdentifier, which is what XCUITest, Appium's XCUITest driver and Maestro query. Before Compose Multiplatform 1.8.0, the iOS accessibility tree synced only while an accessibility service ran. On those versions, test builds setaccessibilitySyncOptions = AccessibilitySyncOptions.Always(debugLogger = null)inComposeUIViewController(configure = { ... }). From 1.8.0 the option is gone: the tree loads on demand. Compose's iOS accessibility support is still maturing. Confirm each ID in Accessibility Inspector (Xcode > Open Developer Tool > Accessibility Inspector; look at "Identifier"), or printapp.debugDescriptionin a UI test. - SwiftUI and UIKit expose
accessibilityIdentifierdirectly. If you put an identifier on a container without.accessibilityElement(children: .contain), SwiftUI may apply it to every child element. Use the container helper above, then confirm in Accessibility Inspector.
- Compose tests (
- Add spoken labels separately, only where needed. Material and SwiftUI controls with visible
text already have a label. Add
contentDescription/accessibilityLabelto icon-only buttons, images that carry meaning, and rows that need a summary. Never put an ID in a label. A screen reader would read "notes underscore list underscore add underscore button" aloud, and every copy change would then break a test. - Find elements in tests by ID. The per-tool syntax and pitfalls are in
references/finding-elements.md: Appium UiAutomator2 (which adds a
package prefix to
idlocators by default), Appium XCUITest, Maestro, XCUITest, UiAutomator and Compose tests. Done when each new ID is located once by the real tool, on each platform that ships the screen.
Quick reference
| Concept | Compose (Android and CMP) | SwiftUI / UIKit |
| Locator (tests) | Modifier.testTag("id") | .accessibilityIdentifier("id") |
| Spoken label | contentDescription / semantics { contentDescription = ... } | .accessibilityLabel(...) |
| Hint | semantics { onClick(label = ...) } (action label) | .accessibilityHint(...) |
| Expose to UiAutomator / Appium / Maestro | semantics { testTagsAsResourceId = true } on a root (androidMain) | automatic |
| Seen on iOS as | accessibilityIdentifier (Compose Multiplatform) | accessibilityIdentifier |
| ID home | <Screen>TestTags object in commonMain | enum AccessibilityID { enum <Screen> { ... } } |
| Composite rows | tag the clickable row root; useUnmergedTree = true to reach children in Compose tests | .accessibilityElement(children: .contain) + identifier |
Verify
- Compile: the module builds on both platforms (
kmp-build-check, or the module's compile tasks). - Android: open Appium Inspector or
uiautomatorviewer, or runadb shell uiautomator dump. Each tagged element showsresource-idequal to the tag. If there is noresource-id, thetestTagsAsResourceIdancestor is missing for that window. - iOS: in Accessibility Inspector, or in Appium Inspector (the
nameattribute), each element shows its identifier. - Tests: the new IDs are found by the project's real suite (Maestro flow, Appium test, XCUITest or Compose test).
Report each item as PASS, FAIL or NOT RUN with the reason. For example: "iOS inspection NOT RUN: no macOS host". Never report an ID as working on a platform where it was not inspected or exercised.
Common mistakes
| Mistake | Why it bites | Fix |
ID placed in contentDescription / accessibilityLabel | Screen readers read it aloud; translations and copy edits break tests | Locator in testTag / accessibilityIdentifier; prose in the label |
No testTagsAsResourceId ancestor | UiAutomator, Appium and Maestro on Android see no resource-id | Set it once at the Activity root, and again in dialog or sheet windows |
semantics { testTagsAsResourceId = true } in commonMain | It is an Android API, so the common compile fails | androidMain host root, or an expect/actual modifier |
Appium id locator for a Compose tag | UiAutomator2 adds <package>:id/, so the bare tag never matches | Turn off ID autocompletion, or use a UiSelector().resourceId(...) (see reference) |
Appium accessibility id on Android for a testTag | On Android it matches content-desc, not the tag | Use resource-id on Android; accessibility id works on iOS |
| Row ID built from the list index | Reordering or paging taps the wrong row silently | row(note.id) from a stable model ID |
Tag on an inner Text instead of the clickable node | The test taps a node that has no click action | Tag the node that has the click handler |
Identifier on a SwiftUI stack without .contain | Children may all inherit the same identifier | .accessibilityElement(children: .contain) first |
| Inline ID strings at call sites | Renames drift between the app and the tests | Constants in TestTags / AccessibilityID |
| Different schemes on Android and iOS | One cross-platform flow cannot drive both | One <screen>_<element>_<role> string per element |
| ID parameters added to every design-system component | API noise; IDs end up inside components | Tag at the call site; add a sub-element ID parameter only for a documented QA need |
Review checklist
- Every new interactive element, screen root, dialog action, error, empty state and retry has an ID.
- IDs come from the screen's
TestTagsobject or theAccessibilityIDenum. No inline literals. - IDs follow
<screen>_<element>_<role>(or the project's existing scheme) and are identical across platforms. - Dynamic rows use a stable model ID. Lazy lists also use
key = { it.id }. - Locator and spoken label are separate, and labels are localised.
- Android: one
testTagsAsResourceIdroot per window (activity, dialogs, sheets), set fromandroidMain. - SwiftUI: composite rows use
.accessibilityElement(children: .contain). - Each new ID has been seen in an inspector, or found by a test, on each platform.
Related skills
kmp-testing-patterns: Compose UI tests withonNodeWithTag, plus unit tests for the state behind the screen.kmp-build-check: confirm the tagged screens compile on both platforms.compose-multiplatform-screen: the screen shape that owns a<Screen>TestTagsobject.compose-design-system-component: design-system components, tagged at the call site.- dadederk/iOS-Accessibility-Agent-Skill: VoiceOver, Dynamic Type and accessible SwiftUI/UIKit.
- skydoves/android-testing-skills: Compose UI testing, UiAutomator and Espresso skills.
- chrisbanes/skills: Compose skills, including UI testing patterns.
- AvdLee/Swift-Testing-Agent-Skill: Swift Testing.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

