Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-10 17:37:21 +03:00
parent 8ea23a2dd7
commit 4f1710d77c
23 changed files with 884 additions and 29 deletions

View file

@ -129,6 +129,47 @@ When the user asks to **port** an iOS test to Android:
Scenario files orchestrate flows; they must not define page objects or duplicate generic helpers.
### Page-object matchers: exhaust the native Kakao API before dropping to raw Compose
**Reviewers reject raw `composeTestRule` / `semanticsProvider.onNode(...)` / `onAllNodes(...)[i]` and
deep nested matchers when a native Kakao-Compose mechanism does the same thing.** Before writing any
such construct, look for the built-in KNode / `ViewBuilder` / `KLazyListNode` API — it almost always
exists. The raw form is a last resort, and even then it stays **inside the page object**, never in the
test body (the test only calls page-object members and scenarios — no `composeTestRule`, no test tags,
no `onNode`/`onAllNodes`, no bare matchers leak into it).
Native first, by need:
- **N-th of several identical nodes**`child { … ; hasPosition(index) }` (Kakao maps
`NodeMatcher.position``onAllNodes(matcher)[index]` for you). Do **not** hand-roll
`semanticsProvider.onAllNodes(matcher)[index]`.
- **Scroll to index / matcher / key** → inside a KNode block: `knode { performScrollToIndex(index) }`,
`knode { performScrollToNode(matcher) }`, `knode { performScrollToKey(key) }` (mirror
`MarketsPageObject.scrollToListedOnBlock`). These wrappers are `@ExperimentalTestApi`, so annotate the
page-object method `@OptIn(ExperimentalTestApi::class)` — that opt-in is expected, not a smell.
- **Relationship filters**`ViewBuilder` DSL inside `child { }`: `hasAnyChild`, `hasAnySibling`,
`hasAnyAncestor`, `hasAnyDescendant`, `addSemanticsMatcher(matcher)`, `useUnmergedTree = true`.
- **Lazy list / pager item (esp. below the fold)**`KLazyListNode` + `childWith { … }` / `childAt(index)`
(see `AddFundsBottomSheetPageObject`, `BuyTokenPageObject`), not a manual scroll + `onAllNodes`.
- **A raw Compose-Test op with no KNode wrapper** (e.g. `captureToImage()`, and any other
`SemanticsNodeInteraction` extension Kakao doesn't surface) → do **not** fall back to
`composeTestRule.onNode(hasTestTag(...))` in the scenario. Every KNode exposes a public `delegate`, and
the built-in actions/assertions are all just `delegate.perform(type) { <this: SemanticsNodeInteraction> }`
/ `delegate.check(type) { … }`. `ComposeOperationType` is an open interface, so declare a tiny private
`enum class Xxx : ComposeOperationType { … }` and reach the underlying `SemanticsNodeInteraction` from a
**page-object method** — reusing an existing KNode (its testTag + `useUnmergedTree`). Capture a return
value via a `lateinit var` written inside the lambda. Example (`TokenReceiveQrCodeBottomSheetPageObject.captureQrCodeBitmap`):
```kotlin
fun captureQrCodeBitmap(): Bitmap {
lateinit var bitmap: Bitmap
qrCode.delegate.perform(QrCodeAction.CAPTURE) { bitmap = captureToImage().asAndroidBitmap() }
return bitmap
}
private enum class QrCodeAction : ComposeOperationType { CAPTURE }
```
This keeps `composeTestRule` / test tags out of the scenario — the scenario just calls the page-object method.
- **Only if truly nothing fits**`semanticsProvider.onNode(...)` / `onAllNodes(...)` (the escape hatch
used by `MainScreenPageObject`), wrapped in a named page-object method with a one-line WHY comment.
### Strings
- **No hardcoded UI text** in matchers. Use `getResourceString(R.string.foo)` from
@ -164,20 +205,14 @@ Scenario files orchestrate flows; they must not define page objects or duplicate
that a screen "never idles", **cold-boot a fresh emulator** (`emulator -avd … -no-snapshot -wipe-data
-memory 4096 -cores 2`) and re-run. A suite that flaked across runs on a tired emulator can be a clean
10/10 on a fresh one (verified on this exact suite). Don't rewrite waits to work around emulator rot.
- **In scenario / `BaseTestCase`-extension code, `flakySafely` is NOT available** regardless — use the
same `composeTestRule.waitUntil` fallback (or `waitUntilAtLeastOneExists(matcher, timeout)` to wait for
appearance, `{ a exists || b exists }` for either/or).
- **Don't repeat the `composeTestRule.waitUntil(timeoutMillis = …) { runCatching { … }.isSuccess }`
block across steps — extract a one-line private helper** in the scenario file and call that instead.
A multi-step scenario that gates every async step this way turns into copy-paste noise (and reviewers
flag it). Add once, near the top of the file:
```kotlin
// flakySafely is unavailable in BaseTestCase extensions — wait until the assertion/action stops throwing.
private fun BaseTestCase.awaitSuccess(block: () -> Unit) {
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT) { runCatching(block).isSuccess }
}
```
then each step reads `awaitSuccess { onXxxScreen { field.assertExists() } }` before the action.
- **In scenario / `BaseTestCase`-extension code, `flakySafely` is NOT available** regardless — use
`BaseTestCase.awaitSuccess(timeoutMillis = WAIT_UNTIL_TIMEOUT) { … }` (a shared member on `BaseTestCase`,
no import needed) which wraps `composeTestRule.waitUntil { runCatching(block).isSuccess }`. Each async step
reads `awaitSuccess { onXxxScreen { field.assertExists() } }` before the action. For appearance-only waits
`composeTestRule.waitUntilAtLeastOneExists(matcher, timeout)` (or `{ a exists || b exists }` for either/or)
is also fine.
**Do not re-declare a private `awaitSuccess` in a scenario file** — the shared `BaseTestCase.awaitSuccess`
already exists; older files may still have a private copy, don't copy that pattern.
- **Right-size the timeout — don't stamp `WAIT_UNTIL_TIMEOUT_LONG` on every step.** The timeout is a
*ceiling*, not a sleep (`waitUntil` returns the moment the condition holds), but the default
`WAIT_UNTIL_TIMEOUT` (20 s) already dwarfs a normal async transition. Reserve `…_LONG` / `…_VERY_LONG`