246 lines
No EOL
14 KiB
Markdown
246 lines
No EOL
14 KiB
Markdown
# 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 `MotionEvent`s; 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:**
|
|
|
|
```kotlin
|
|
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".
|
|
|
|
```kotlin
|
|
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`:
|
|
|
|
```kotlin
|
|
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.
|
|
|
|
```kotlin
|
|
// 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:
|
|
|
|
```kotlin
|
|
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`).
|
|
|
|
## Touch auto-scroll gets hijacked by a nested-scroll container (e.g. a bottom sheet)
|
|
|
|
When a screen hosts a nested-scroll container (a Material3 bottom sheet, `PullToRefreshBox`), Kakao's
|
|
**touch-based** auto-scroll toward a below-the-fold target can be consumed by that container instead —
|
|
expanding the sheet over the content, so the next click lands on the wrong element.
|
|
|
|
- **Scroll with semantics, not touch:** `onNode(CONTAINER).performScrollToNode(matcher)` issues a
|
|
`ScrollToIndex` action that does NOT engage nested scroll.
|
|
- **Don't `device.pressBack()` to collapse the sheet** on a root screen — its `BackHandler` only fires
|
|
when already expanded, races the press, and back often falls through and quits the app.
|
|
|
|
## A perpetually animating screen keeps Compose non-idle → idle-synced actions flake
|
|
|
|
Kakao/Compose-test actions block on Compose reaching *idle* first. A screen that animates forever — an
|
|
auto-advancing stories/onboarding carousel, a looping shimmer, a never-ending spinner — never idles, so
|
|
`clickWithAssertion()` / `assertIsDisplayed()` on it flake (`… is not displayed`, or
|
|
`ComposeNotIdleException`). **First rule out a degraded emulator** (see running-and-debugging) — a
|
|
slow-*loading* screen on a tired emulator throws the identical exception but is fixed by a cold-boot, not
|
|
by changing the test. Only treat it as a *truly* infinite animation if it reproduces on a fresh emulator.
|
|
|
|
For a genuinely infinite animation, **remove the screen at its source rather than out-waiting it:** most
|
|
are gated by a feature toggle or a mock response — flip it off so the screen never renders. If it's
|
|
server-driven, set the toggle **before app launch** (config is fetched at startup), not mid-test.
|
|
(Example: the swap first-time stories are disabled via their WireMock scenario, then opened with
|
|
`storiesExist = false`.) Note that `waitUntilAtLeastOneExists(hasTestTag(TAG))` polls the **merged** tree
|
|
(no `useUnmergedTree` option), so a `clickable` node inside a `mergeDescendants` container — which exists
|
|
only in the *unmerged* tree — will never match it; poll through the page object instead.
|
|
|
|
## 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]):
|
|
|
|
```kotlin
|
|
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 = "")` 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:
|
|
|
|
```kotlin
|
|
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. |