Updated on 2026-08-14
This commit is contained in:
parent
8ea23a2dd7
commit
4f1710d77c
23 changed files with 884 additions and 29 deletions
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue