← all articles

Why Jetpack Compose elements don't show up in the UiAutomator tree

You open Appium Inspector against a screen you know has a login form. The old XML-layout screens next to it show a tidy tree of EditText, Button and TextView nodes with resource ids. The Compose screen shows a handful of generic android.view.View nodes, some with no id at all, and the button you want to tap is nowhere in the page source. Or it is there, but its label has been fused with three other labels into one string. This is the question behind issue 988 on the appium-uiautomator2-driver repo, and a version of it lands in my inbox every few weeks.

The stakes are practical. If you run UI automation for a living, whether that is regression suites, app-store smoke checks or a device farm that executes someone else’s flows, a Compose migration can quietly break half your locators. The app did not get less accessible. Your tooling is reading a different structure than the one you were used to. Teams then reach for coordinate taps, which work until the first font-scale change or the first foldable.

The short answer is that this is not a bug in UiAutomator2 or in Compose. Compose does not create one View per composable, so there is no view hierarchy for UiAutomator to dump. What UiAutomator sees is a projection of Compose’s semantics tree, filtered and merged for accessibility services, with testTag hidden unless the app opts in. Once you know which of those three steps is eating your element, the fix is usually one line in the app or one change in your locator. This article is about telling those cases apart. I am assuming you can already write an Appium session and read a page source dump.

Background and prior art

Classic Android UI is a tree of View objects. Each view can report itself to accessibility through AccessibilityNodeInfo, and UiAutomator’s dump is basically a walk over those nodes via the accessibility APIs. That is why resource-id, class, text and content-desc in the XML line up so neatly with the view system. If you want the longer story of how UiAutomator sits on the accessibility layer, I covered it in UiAutomation vs AccessibilityService, and the selector side in UiObject vs UiObject2.

Compose breaks the one-view-per-element assumption. A whole Compose hierarchy is hosted inside a single AndroidComposeView, and your Column, Row, Text and Button are layout nodes drawn by Compose itself. Google’s answer for tooling is the semantics tree, documented in the official Semantics in Compose guide. Each layout node can carry semantic properties such as text, content description, role, click actions and a test tag. Accessibility services and the Compose test framework both read that tree. UiAutomator is an accessibility client, so it only gets what Compose chooses to publish as virtual accessibility nodes.

The previous generation of tools, Espresso and the old ViewMatchers, never had this problem on Compose in the same way because they were view-based and simply could not see inside it. Google shipped createComposeRule() and onNodeWithTag() in the Compose testing docs as the in-process replacement. That works well if you own the source and can run instrumentation tests. It does nothing for a black-box Appium suite, which is where this article lives.

The core mechanism

There are four separate things that decide whether a Compose element reaches your Appium session. People usually blame the first, but the cause is more often the second or third.

Step 1: one view, many virtual nodes

AndroidComposeView implements an accessibility delegate that answers AccessibilityNodeProvider queries. For each semantics node that should be exposed, it creates a virtual AccessibilityNodeInfo with its own virtual id, bounds, text and actions. UiAutomator2, which runs on Android’s UiAutomation and walks AccessibilityNodeInfo trees, then receives those virtual nodes as children of the host view.

The consequences:

  • there is no real View for a Compose Button, so there is no android.widget.Button unless the semantics role maps to one. Compose sets the reported class name from the role and content, so you get android.widget.Button, android.widget.TextView, android.widget.CheckBox and so on for some nodes, and plain android.view.View for the rest. Do not write locators that depend on class name for Compose screens.
  • there is no layout XML id. Anything resembling resource-id has to be synthesised from semantics.
  • a composable that sets no semantics and has no semantic children produces no node at all. A decorative Box with a background is invisible to accessibility, which is correct behaviour.

Step 2: merging

This is the one that surprises people. In the semantics tree, some nodes are merge boundaries. Setting mergeDescendants = true on a node tells accessibility to treat the node and its descendants as one unit. Modifier.clickable, Modifier.toggleable, Modifier.selectable and the Material Button set this, as do many Material components. The accessibility reasoning is sound. A screen reader should say “Pay now, button” once, not “icon, Pay now, button”.

For the accessibility tree there are two views of the same structure. The unmerged tree has every semantics node. The merged tree collapses merge boundaries: the parent node absorbs its children’s text, content descriptions and actions, and the children disappear from what an accessibility service sees. Compose’s own tests expose both, since onNodeWithTag("x", useUnmergedTree = true) exists precisely because a tag on a child of a merged node is not found in the default merged tree.

UiAutomator only ever sees the accessibility-facing merged result. So:

  • a Text("Total") and a Text("$12.40") inside a clickable Row come out as a single node whose text is the concatenation, something like Total $12.40. Exact-match locators on either label fail.
  • an Icon with a content description inside a Button is gone as a separate node. Its description is folded into the button’s.
  • a testTag placed on a child of a merged parent is not visible at all, as there is no node left to hold it.

Step 3: testTag is not resource-id by default

Modifier.testTag("login_button") stores the tag in the semantics tree under SemanticsProperties.TestTag. It is for Compose’s own test framework. By default it is not copied into the accessibility node, so UiAutomator’s resource-id stays empty. That is deliberate: tags are test plumbing and Google did not want them leaking into production accessibility data.

There is an opt-in. On Android, testTagsAsResourceId is a semantics property that tells the accessibility delegate to publish testTag values as the node’s viewIdResourceName. It is marked experimental in the API. You set it on an ancestor and it applies to the descendants below it:

@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun AppRoot() {
    Box(
        Modifier.semantics { testTagsAsResourceId = true }
    ) {
        AppNavHost()
    }
}

With that in place, a Modifier.testTag("login_button") on a node that survives merging shows up as resource-id="login_button" in the UiAutomator2 dump. Note that the id is the bare tag, with no com.example.app:id/ package prefix, because there is no R.id behind it. Locators like By.id("login_button") or new UiSelector().resourceId("login_button") work, and in Appium you can use AppiumBy.id("login_button"). The package-qualified form some people copy from view-based apps will not match.

The property was added in the Compose UI 1.2 era if memory serves, but check your own Compose version rather than trusting me on the exact number. Because it is experimental and its behaviour is documented in API reference rather than in a stable guide, treat anything I say about it as true as of October 2026 and re-verify after a Compose BOM bump.

Step 4: explicit hiding

Developers can remove nodes from the accessibility tree on purpose:

  • Modifier.clearAndSetSemantics { } wipes the node’s and its descendants’ semantics and replaces them with whatever you declare. A tag set inside is gone.
  • Modifier.semantics { invisibleToUser() } (and the newer hideFromAccessibility() on recent Compose releases) marks a node as not for accessibility services. UiAutomator will not see it.
  • Modifier.semantics { contentDescription = "..." } on a container can override or replace the descendants’ text depending on merge configuration.

None of this is a driver bug. If your app team applied clearAndSetSemantics to a custom keypad because the TalkBack experience was noisy, the keys are gone from every accessibility client, yours included.

Why dumps look different between runs

Compose sends accessibility semantic-change events on a short delay, on the order of 100 ms by default, and builds virtual nodes lazily when an accessibility service is attached. UiAutomator2 attaching mid-animation can therefore catch a tree that is one frame behind what you see on screen. This matters for the edge cases below.

Worked examples

These are constructed examples I use to explain the behaviour. They are built from the semantics rules above and not benchmarks from a specific device or app, so count nodes from your own dumps rather than trusting my numbers as measurements.

Example 1: the tagged button that has no id

Source:

Button(
    onClick = { submit() },
    modifier = Modifier.testTag("pay_button")
) {
    Icon(Icons.Default.Lock, contentDescription = "Secure")
    Text("Pay now")
}

Without testTagsAsResourceId, the dump gives one node for the button: class android.widget.Button, text="Pay now", content-desc probably empty or holding the icon description, clickable="true", resource-id="". So By.id("pay_button") finds 0 elements, which is the behaviour the issue 988 reporter’s symptom points to. The element is present, only the tag is hidden.

The two fixes, in order of preference:

  • ask the app team to set testTagsAsResourceId = true once at the root of the test build. After that the same node reports resource-id="pay_button". 1 line in the app, 0 locator changes beyond switching to id.
  • if you cannot touch the app, locate by text. new UiSelector().text("Pay now") matches because the merged node carries the visible label. Be aware that if the button’s text ever changes with locale, your locator changes with it.

Example 2: two labels fused into one

Source:

Row(
    Modifier.clickable { openReceipt() }
) {
    Text("Order 4417")
    Text("Shipped")
}

Because clickable merges descendants, the dump has one node with text along the lines of Order 4417 Shipped. The order and the separator are Compose’s choice. Roughly 2 labels become 1 node, and exact matching on Shipped returns 0 matches.

Counter-strategies:

  • use textContains("Shipped") or textMatches with a regex. This is the pragmatic fix for black-box work.
  • if you need the status as separate data, ask for Modifier.semantics { stateDescription = "Shipped" } on the row. stateDescription is a proper accessibility field and reads cleanly. In Appium it arrives as part of the node’s extras on recent API levels, but check whether your driver version surfaces it in the XML. If it does not, the page source will not show it.
  • do not ask the app team to remove clickable merging to make your life easier. That makes the screen worse for TalkBack users, and they will rightly refuse.

Example 3: the list that is only partly there

Source: a LazyColumn with 200 rows of Text("Item $i"), tagged row_$i.

Compose only composes items near the viewport, so a dump on a typical phone screen shows perhaps 10 to 14 of the rows depending on screen height and row size. The other 186 or so do not exist as nodes, whatever your locator says. By.id("row_150") returns 0 until you scroll to it, and even then only once it is composed.

Counter-strategy: use UiScrollable with scrollIntoView, or Appium’s mobile: scrollGesture with a loop, and check after each swipe. There is nothing to be done by configuration, because the nodes truly do not exist yet. If the app team set Modifier.semantics { collectionInfo } (LazyColumn does set collection info), you can read total counts from that where the driver surfaces it, but do not rely on it.

If you are using the Java client for Example 1’s locator style, it is worth making sure the client and Selenium jars match, which I wrote up in why Appium’s Java client and Selenium versions have to match. A mismatched pair produces locator errors that look suspiciously like missing Compose nodes and are not.

Edge cases and failure modes

Dialogs, popups and bottom sheets live in other windows

Dialog, DropdownMenu, ModalBottomSheet and Popup each create their own window. UiAutomator’s default root dump covers the active window. If your dropdown items are missing but the screen behind it is fine, you are probably looking at the wrong window. The uiautomator2 driver has a multi-window setting (enableMultiWindows) that adds other windows to the dump. Turn it on in the session settings and re-dump before you blame Compose. Check the driver’s README for the current name and behaviour, because settings get renamed and I would rather you read the source of truth at the driver repo.

Tag scope: the property is inherited downward only

testTagsAsResourceId applies to the node that declares it and its descendants. If you set it inside one screen and the dialog is a separate window rooted elsewhere, the dialog’s tags are still hidden. Set it where each window’s content starts, which in practice means inside the dialog’s content lambda as well.

Duplicate or reused tags

Compose will happily let you use testTag("item") on 40 rows. The accessibility dump then has 40 nodes with resource-id="item". findElement returns the first, which may not be the one you meant. Use findElements plus index, combine with text, or ask for tags that include the item key. It also pays to ask for tags without spaces or exotic characters, since the id goes through XPath and selector strings in your tooling.

Timing and half-built trees

Because semantics events are batched and nodes build lazily, an immediate dump after navigation can omit the new screen’s nodes. Symptoms: an element is “not found” on the first query and found on the second 200 ms later. Fixes are explicit waits on the element you care about, plus checking the driver settings for waitForIdleTimeout and waitForSelectorTimeout. Compose animations also keep the app non-idle for longer than XML views did, so a short idle timeout can be the opposite problem: you query too early on a screen that never settles. I have not found one setting that is right for all apps, so I tune per suite.

Canvas, custom drawing and interop views

Anything drawn in a Canvas with no semantics is invisible. Charts and custom keypads are the usual victims. Either the app adds semantics for the drawn elements (the right answer, since screen readers need them too) or you fall back to image matching, which I treat as a last resort. AndroidView interop is the opposite case: a real View hosted inside Compose appears in the dump as a normal view with its own resource id, so those parts of the screen look like the old days.

A note on scope. All of this is for apps you own or are authorised to test. If an app deliberately strips its accessibility semantics as a protection measure, working around that is not something I will write up, and you should talk to whoever owns the app instead.

What we learned in production

The pattern that has saved me the most time is boring: agree with the app team on a tagging convention before the first test is written, and ship testTagsAsResourceId = true in a debug or QA build variant only. Tags that name the screen and role, like login_email_field and login_submit, survive redesigns far better than text locators and do not break when marketing changes copy or when the build is localised. When I cannot get the flag, I accept text and textContains locators for Compose screens and write them in one page-object file so a copy change is a one-line fix. Fusing locators across the suite is how a copy tweak turns into 40 red tests.

The other lesson is to diagnose before you theorise. When an element is missing I dump the page source, then ask three questions in order: is a node there at all (tree, windows and lazy lists), is it there but fused (merging), is it there but without an id (the tag flag). Most of the “Compose is broken in Appium” reports I have helped with were the second or third. For sanity checks on a physical device, a real phone beats an emulator because accessibility service behaviour, animation scale and OEM overlays differ. If you do not have the handset, cloudf.one rents real Android phones in Singapore on dedicated hardware, controlled from the browser. One limit to know before you plan around it: there is no self-service adb. adb and Appium reach a rented phone only through a WireGuard tunnel that we set up on request by email to [email protected], so it suits manual checks of how a screen behaves better than a quick Appium loop. It is Singapore-only, so it will not help if you need to test on devices elsewhere. For larger fleets, the trade-offs in what an Android device farm actually is matter more than any one vendor.

References and further reading

Written by Xavier Fok

disclosure: this article may contain affiliate links. if you buy through them we may earn a commission at no extra cost to you. verdicts are independent of payouts. last reviewed by Xavier Fok on 2026-10-20.

free download
The adb and scrcpy command cheat sheet

The commands we reach for on a rack of real phones, on one printable page. Leave your email and we will also tell you when we publish a new guide, a few times a month at most.

from the team behind cloudf.one
Passes on the emulator, fails on a real phone?

cloudf.one rents real Android phones in Singapore that you control from the browser, each on a persistent Singapore mobile IP. Real hardware, a real OEM build and a real carrier network, without buying and racking the phones yourself.

test on a real phone →
read on
More from ADB Handbook

adb commands, scrcpy flags, automation frameworks, and why a test passes on one phone and fails on the next.

browse all articles →