16 KiB
Compose UI test traps
Each of these has a silent failure mode: the gesture/action appears to run, the test stays green (or fails for the wrong reason), but the intended behavior never fired. Diagnose with logcat network traces or a semantics-tree snapshot, not by visually watching the swipe.
Material3 PullToRefreshBox + UiAutomator swipe = silent no-op
androidx.compose.material3.pulltorefresh.PullToRefreshBox reacts to overscroll deltas via Compose's
NestedScrollConnection from the inner LazyColumn. UiAutomator's device.swipe(x1,y1,x2,y2,steps)
dispatches platform MotionEvents; the LazyColumn receives them as an ordinary scroll, never
produces overscroll, and onRefresh never fires — regardless of steps=30 (fling) or steps=1000
(slow drag). Confirmed by NetworkLogs: zero refresh calls after the UiAutomator swipe, vs. one
immediate call via the Compose Test API.
Use the Compose Test API:
composeTestRule.onNode(hasTestTag(SOME_TAG_INSIDE_THE_BOX))
.performTouchInput {
swipeDown(startY = 0f, endY = visibleSize.height.toFloat() * 6f, durationMillis = 800)
}
The shared pullToRefresh() in common/extensions/UiDeviceExt.kt is UiAutomator-based and works for
some screens (a different refresh container), but not for Material3 PullToRefreshBox. When
porting a test, verify with a logcat network trace, not visual inspection.
TangemHoldToConfirmButton semantics are minimal
The component exposes ONLY TestTag, IsContainer, Shape in Compose semantics — no Disabled,
Role, or OnClick. assertIsEnabled() / assertHasClickAction() are useless on it.
Modifier.holdToConfirmGestures(enabled, ...) early-returns from pointerInput when enabled=false,
so the hold gesture is silently swallowed: the button looks fine, the user holds, nothing happens,
onConfirm never fires.
Diagnose "silently disabled" from a test:
- Snapshot the Compose semantics tree before the hold.
- Perform the hold:
performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }. - Snapshot again — byte-identical trees mean
onConfirmdidn't run. - Or check WireMock request stats for the downstream API call expected after
onConfirm.
Asserting enabled/disabled on a Modifier.clickable row
When a settings/list row puts Modifier.clickable(enabled = isClickable, ...) on the row container
(not the title Text), the enabled/disabled state lives on that container; the child Texts only carry
testTag/text. So assertIsEnabled() / assertIsNotEnabled() must target the container, matched by a
descendant text — not the title node itself.
Match the container in BOTH states with click-action OR disabled-semantics. Do NOT rely on
hasClickAction() alone: depending on the Compose version a clickable(enabled = false) row may not
expose an onClick action, so a hasClickAction()-only matcher finds no node and assertIsNotEnabled()
fails with "No node found".
import androidx.compose.ui.test.hasClickAction as withClickAction
import androidx.compose.ui.test.isNotEnabled as withDisabled
val row: KNode = child {
addSemanticsMatcher(withClickAction() or withDisabled()) // matches enabled AND disabled rows
hasAnyDescendant(withText(getResourceString(R.string.row_title))) // narrows to the specific row
useUnmergedTree = true
}
// enabled card: row.assertIsEnabled() ; disabled card: row.assertIsNotEnabled()
assertTextContains(x) defaults to exact-segment match, not substring
SemanticsNodeInteraction.assertTextContains(value, substring = false, ignoreCase = false) defaults to
substring = false — it asserts that some text segment of the node equals value exactly. Matching
a symbol or fragment inside a larger string (e.g. "€" against a balance "€108,474.21") silently
never matches and times out inside a waitUntil. Pass substring = true:
totalBalanceText.assertTextContains("€", substring = true)
Reference tests that pass the full string (assertTextContains("€108,474.21")) work with the default,
which is why a copy-pasted matcher can mislead.
Kakao-Compose child { }: use DSL matchers, not raw Compose matcher aliases
Inside a child { … } / ComposeScreen element builder, call the DSL methods (hasText(...),
hasTestTag(...), hasAnyDescendant(...)). A common alias is import androidx.compose.ui.test.hasText as withText — but withText(x) as a bare statement inside the builder just creates a
SemanticsMatcher and discards it, registering nothing → ViewBuilderException: Please set matchers for your Element! at run time. withText/raw matchers are only valid as arguments to a DSL method
(hasAnyDescendant(withText(name))), never as a standalone line.
// WRONG — no matcher registered
fun walletNameValue(name: String) = child { withText(name); useUnmergedTree = true }
// RIGHT
fun walletNameValue(name: String) = child { hasText(name); useUnmergedTree = true }
LazyList item below the fold: plain child { } finds it but can't click it
A child { hasTestTag(ITEM); hasAnyDescendant(withText(name)) } matcher resolves the semantics node
even when the item is composed off-screen (LazyColumn keeps a few items past the viewport). But the
node isn't displayed, so clickWithAssertion() (assertIsDisplayed() first) fails, or performClick()
taps nothing. Symptom: the test passes when the item happens to be near the top and fails for items
lower in the list — and a manual swipe "fixes" it. Do not patch with a swipe (flaky, the
clickableSingle 500ms debounce can also eat fast programmatic clicks).
Whenever a target lives in a LazyColumn/LazyRow and might be below the fold, build a KLazyListNode
matcher up front — childWith scrolls the list to the item before returning it:
import com.tangem.common.utils.LazyListItemNode
import com.tangem.core.ui.utils.LazyListItemPositionSemantics
import io.github.kakaocup.compose.node.element.lazylist.KLazyListNode
private val tokensList = KLazyListNode(
semanticsProvider = semanticsProvider, // primary-ctor param is in scope in initializers
viewBuilderAction = { hasTestTag(SomeScreenTestTags.LAZY_LIST) }, // the LazyColumn's OWN tag
itemTypeBuilder = { itemType(::LazyListItemNode) },
positionMatcher = { position -> SemanticsMatcher.expectValue(LazyListItemPositionSemantics, position) },
)
@OptIn(ExperimentalTestApi::class)
fun tokenWithTitle(title: String): LazyListItemNode =
tokensList.childWith<LazyListItemNode> {
hasTestTag(SomeScreenTestTags.LAZY_LIST_ITEM)
hasText(title)
useUnmergedTree = true
}
Non-obvious points that bite:
childWithsearches the MERGED tree (it scopes via the list'sviewBuilderAction, whoseuseUnmergedTreedefaults tofalse). So match the item byhasText(title)— on aMergeDescendantsitem the child texts aggregate onto the item node.hasAnyDescendant(withText(...))does not match there. (useUnmergedTree = trueon the item matcher is inert for the scroll/filter but harmless; keep it to mirror existing page objects.)- The list needs its OWN
testTagon theLazyColumn. If production tags only the items (e.g.MARKETS_TOKENS_LIST_ITEM) and not the container, add a tag to theLazyColumnmodifier in the production composable. Reuse the screen's existing…TestTags.LAZY_LISTconstant when one fits. - Scope to the right list when several coexist. Multiple LazyColumns with the same item tag can be
composed at once (e.g. the Add-Funds
ChooseTokenScreenlist AND the main-screen markets sheet, both usingMARKETS_TOKENS_LIST_ITEM). A bare top-levelchild { hasTestTag(ITEM); … }is then ambiguous and may match the wrong screen.childWith(andtokensList.child { … }) scope through the container tag viaonNode(LAZY_LIST)/hasAnyAncestor(LAZY_LIST), so they pick the intended list. Prefer a unique container tag over hoping the item text is unique. childWithreturns aLazyListItemNode, not aKNode.clickWithAssertion()was aKNodeextension; it's been generalized tofun BaseNode<*>.clickWithAssertion()(incommon/extensions/KNode.kt) so it works on both. Both types extendBaseNode, andassertIsDisplayed()/performClick()live onBaseNode.positionMatcheris only used bychildAt(index)/hasLazyListItemPosition. ForchildWith(match-by-content) the items don't need to exposeLazyListItemPositionSemantics— pass the matcher anyway since the constructor requires it.
Reference: AddFundsBottomSheetPageObject.trendingTokenWithTitle and MainScreenPageObject (lazyList).
Main-screen Markets bottom sheet swallows touch-based auto-scroll
The main screen hosts a Material3 Markets bottom sheet (nested scroll, like PullToRefreshBox above).
Kakao's touch-based auto-scroll — fired when a target is below the fold (an account card, the
"Generate addresses" button) — is handed to the sheet via nested scroll and expands it over the
list; the next click then lands on a market token (you end up on an unrelated token's details, e.g.
TRON). Symptoms: autoscroll did not help / "3 click attempts", or the test navigates somewhere random.
- Scroll with semantics, not touch:
onNode(SCREEN_CONTAINER).performScrollToNode(matcher)issues aScrollToIndexsemantics action that does NOT engage the sheet's nested scroll. (SeeMainScreenPageObject.scrollToAccount.) - Do NOT
device.pressBack()to collapse the expanded sheet on the root main screen — back exits the app. The sheet'sBackHandleronly collapses when itscurrentValue == Expanded, which races the press, so back frequently falls through to the activity and quits to the launcher. - Best: avoid the trigger — keep total fiat > 0 (a price quote for the token, see
swap-transfer-accounts.md) so the empty-wallet banner doesn't push the list under the sheet's peek in the first place.
A screen with a perpetual animation keeps Compose non-idle → idle-synced actions flake
General principle. Espresso/Kakao/Compose-test actions block on Compose reaching idle before they
act. A screen that animates forever — an auto-advancing stories/onboarding carousel, a looping shimmer, a
spinner that never stops — never goes idle, so clickWithAssertion(), assertIsDisplayed(), and Kakao
waits on it fail intermittently (… is not displayed, or ComposeNotIdleException). The fix is to get
rid of the non-idle screen, not to out-wait it.
Polling the animated node does NOT fix it — two attempts that look right but aren't:
composeTestRule.waitUntilAtLeastOneExists(hasTestTag(TAG), …)polls the merged tree (it has nouseUnmergedTreeoption). If the target is aclickableelement inside amergeDescendantscontainer (common for tap-to-advance surfaces), its tag lives only in the unmerged tree → the wait never matches → full-timeout on every run.waitUntil { runCatching { onScreen { node.assertIsDisplayed() } }.isSuccess }reads the unmerged tree (good) but Kakao'sassertIsDisplayeditself blocks on idle, and the screen never idles → each probe hangs → the outerwaitUntiltimes out too.
Fix: remove the animated screen at the source. Most such screens are gated by a feature toggle or a
mock response — flip it off so the screen never renders, instead of interacting with it. When the toggle
is server-driven, set it before app launch (additionalBeforeAppLaunchSection, which runs before
ActivityScenario.launch) since the config is usually fetched at startup; setting it mid-test is too late.
Concrete instance (verify against current source — names drift): the first-time swap stories are
controlled by the WireMock scenario stories_first_time_swap_v2. Its Error state returns 500, so the
screen never shows, and openSwapScreen(…, storiesExist = false) skips the close entirely:
setupHooks(
additionalBeforeAppLaunchSection = { setWireMockScenarioState("stories_first_time_swap_v2", "Error") },
additionalAfterSection = { resetWireMockScenarioState("stories_first_time_swap_v2") },
).run {
openSwapScreen(from = SwapEntryPoint.TokenDetails, storiesExist = false)
}
SwapStoriesTest uses this for every flow that isn't specifically testing stories. Only keep
storiesExist = true + clickWithAssertion() when the animated screen itself is the subject under test
(then accept that you're synchronizing against an animation and budget a longer, existence-based wait).
Decompose model lifecycle vs. data refresh
Models (e.g. TangemPayDetailsModel) call data fetches from init {}, NOT on ON_RESUME. Returning
to a screen via router::pop does NOT re-fetch. A test that switches WireMock scenarios between an
action and the assertion MUST explicitly trigger a refresh on the now-frontmost screen — otherwise the
stale in-memory data wins.
Terminal screen never reaches Compose-idle: self-feeding StateFlow loop
A screen whose model writes a fresh state back into the same StateFlow it observes will recompose
forever, so any Compose/Espresso assertion on it times out with ComposeNotIdleException
(autoAdvance=true) or AppNotIdleException "last message = DispatchedContinuation target=Handler"
(autoAdvance=false). The classic shape (hit on the send-v2 ConfirmSuccess screen, [REDACTED_TASK_KEY]):
combine(uiState, currentRoute)
.onEach { (state, _) -> callback.onResult(state.copy(navigationUM = NavigationUM.Content(onClick = { … }))) }
// callback writes back into uiState → emits again → onEach again → ∞
NavigationUM.Content is a data class whose fields are lambdas, recreated every pass → equals
is always false → StateFlow never dedups → unthrottled loop. No test-side workaround helps (it's an
app loop): not flakySafely, not longer timeouts, not mocking external sources, not UiAutomator
(touching the window mid-async-signing aborts the send). Fix is app-side — emit once (guard the
filter/distinctUntilChanged so the self-induced field is ignored). If you see ComposeNotIdle on a
static-looking success/result screen, suspect this before blaming background polling.
Animation-gated content via delay() never appears under the test clock
Compose UI tests run inside runTest — virtual time. A LaunchedEffect { delay(600); visible = true }
that gates the screen body behind AnimatedVisibility(visible) will never reveal it once the
composition is otherwise idle: waitForIdle sees no pending frame-clock awaiters, so it stops without
advancing the virtual clock to the delay's deadline. The body stays empty (you see only the parent
chrome, e.g. a top-bar close icon), the testTag is absent, and assertIsDisplayed fails as
"not displayed" — after burning the full wall-clock timeout. flakySafely(LONG) does NOT help:
it retries in wall-clock time while virtual time stays frozen.
Distinguish from the loop trap above: a delay-gate gives a clean AssertionError: … not displayed
(idle is reached, node just isn't there); the loop gives a ComposeNotIdle/AppNotIdle timeout.
Fixes: (a) app-side — drop the pre-delay, let the enter transition (slideIn/fadeIn) play on the
frame clock (which autoAdvance does pump); or (b) put the asserted testTag on a node outside
the AnimatedVisibility so the container exists from frame 0. A plain coroutine delay is not a
frame-clock awaiter, so advancing frames won't fire it — only advanceTimeBy (with autoAdvance=false)
would, which is fragile. Prefer the app-side fix.
Hot wallet imports with access code
-
openMainScreenWithExistingHotWallet(seedPhrase, accessCode: String = "")inBaseScenarios.kthandles both flows via the optional param — DO NOT introduce a parallelimportHotWalletWithAccessCode. -
Access-code create and confirm screens share the same
ACCESS_CODE_INPUTtestTag. Gate the confirm-screen action on the confirm-screen's unique title:composeTestRule.waitUntilAtLeastOneExists( hasText(getResourceString(CoreUiR.string.access_code_confirm_title)), timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG, ) -
Tangem Pay eligibility (
PaeraCustomer) rejects hot wallets withauthType=NoPassword— those tests must use the access-code path.