tangem-app-android-audited/.claude/skills/write-ui-test/reference/compose-traps.md
2026-06-24 17:38:32 +02:00

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:

  1. Snapshot the Compose semantics tree before the hold.
  2. Perform the hold: performTouchInput { longClick(durationMillis = HOLD_DURATION_MS) }.
  3. Snapshot again — byte-identical trees mean onConfirm didn't run.
  4. 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 frontchildWith 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:

  • childWith searches the MERGED tree (it scopes via the list's viewBuilderAction, whose useUnmergedTree defaults to false). So match the item by hasText(title) — on a MergeDescendants item the child texts aggregate onto the item node. hasAnyDescendant(withText(...)) does not match there. (useUnmergedTree = true on the item matcher is inert for the scroll/filter but harmless; keep it to mirror existing page objects.)
  • The list needs its OWN testTag on the LazyColumn. If production tags only the items (e.g. MARKETS_TOKENS_LIST_ITEM) and not the container, add a tag to the LazyColumn modifier in the production composable. Reuse the screen's existing …TestTags.LAZY_LIST constant 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 ChooseTokenScreen list AND the main-screen markets sheet, both using MARKETS_TOKENS_LIST_ITEM). A bare top-level child { hasTestTag(ITEM); … } is then ambiguous and may match the wrong screen. childWith (and tokensList.child { … }) scope through the container tag via onNode(LAZY_LIST) / hasAnyAncestor(LAZY_LIST), so they pick the intended list. Prefer a unique container tag over hoping the item text is unique.
  • childWith returns a LazyListItemNode, not a KNode. clickWithAssertion() was a KNode extension; it's been generalized to fun BaseNode<*>.clickWithAssertion() (in common/extensions/KNode.kt) so it works on both. Both types extend BaseNode, and assertIsDisplayed()/performClick() live on BaseNode.
  • positionMatcher is only used by childAt(index) / hasLazyListItemPosition. For childWith (match-by-content) the items don't need to expose LazyListItemPositionSemantics — 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 a ScrollToIndex semantics action that does NOT engage the sheet's nested scroll. (See MainScreenPageObject.scrollToAccount.)
  • Do NOT device.pressBack() to collapse the expanded sheet on the root main screen — back exits the app. The sheet's BackHandler only collapses when its currentValue == 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 no useUnmergedTree option). If the target is a clickable element inside a mergeDescendants container (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's assertIsDisplayed itself blocks on idle, and the screen never idles → each probe hangs → the outer waitUntil times 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 runTestvirtual 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 = "") in BaseScenarios.kt handles both flows via the optional param — DO NOT introduce a parallel importHotWalletWithAccessCode.

  • Access-code create and confirm screens share the same ACCESS_CODE_INPUT testTag. 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 with authType=NoPassword — those tests must use the access-code path.