Updated on 2026-08-14
This commit is contained in:
parent
d0e8054535
commit
0ce9be5b08
4 changed files with 94 additions and 89 deletions
|
|
@ -74,6 +74,10 @@ When the user asks to **port** an iOS test to Android:
|
||||||
strings inside `step(...)`.
|
strings inside `step(...)`.
|
||||||
- **Each click is its own** `step("Click on '$x' button")`. Combining clicks into one step hides which
|
- **Each click is its own** `step("Click on '$x' button")`. Combining clicks into one step hides which
|
||||||
click failed in the Allure report.
|
click failed in the Allure report.
|
||||||
|
- **No reusable step-helpers as private functions in the test class.** A sequence reused across tests
|
||||||
|
(e.g. `enterAmount`, `assertReady`) goes in a `scenarios/` file as a `BaseTestCase` extension, not as a
|
||||||
|
private method on the test class — reviewers reject the latter. The test body then calls it wrapped in a
|
||||||
|
`step(...)` like any scenario.
|
||||||
- **Every scenario call in the test body is wrapped in its own `step("…")`**, even though the scenario
|
- **Every scenario call in the test body is wrapped in its own `step("…")`**, even though the scenario
|
||||||
itself contains inner `step(...)`s — the outer step names the flow in the Allure tree, the inner ones
|
itself contains inner `step(...)`s — the outer step names the flow in the Allure tree, the inner ones
|
||||||
detail it (nested steps are expected). `step(...)` (Allure) is callable anywhere, including inside
|
detail it (nested steps are expected). `step(...)` (Allure) is callable anywhere, including inside
|
||||||
|
|
@ -121,13 +125,20 @@ Scenario files orchestrate flows; they must not define page objects or duplicate
|
||||||
|
|
||||||
### Waits and synchronization
|
### Waits and synchronization
|
||||||
|
|
||||||
- **Manual polls are banned** (`onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()` in a loop). Use:
|
- **Manual polls are banned** (`onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()` in a loop) — even
|
||||||
- `composeTestRule.waitUntilAtLeastOneExists(matcher, timeoutMillis)` — wait for one thing to appear.
|
if a bot reviewer suggests one.
|
||||||
- `composeTestRule.waitUntil(timeout) { runCatching { someAssertion() }.isSuccess }` — wait until an
|
- **Default in the test body: `flakySafely(TIMEOUT) { assertion }`** — the codebase idiom (hundreds of
|
||||||
action no longer throws.
|
uses); reviewers prefer it over `composeTestRule.waitUntil { runCatching { … }.isSuccess }`.
|
||||||
- `composeTestRule.waitUntil(timeout) { matcherA exists || matcherB exists }` — the either/or case.
|
- **`ComposeNotIdleException` / `AppNotIdleException` ("busy for ~60s") is usually a sick emulator, not
|
||||||
- **`flakySafely(timeout)`** (Kaspresso) is reachable only from `TestCase` subclasses, NOT from
|
your test.** After many back-to-back local runs the emulator degrades (you may even see a "System UI
|
||||||
extension functions on `BaseTestCase`. In extension code use the `waitUntil` variants above.
|
isn't responding" ANR), and idle-synced ops (`flakySafely`, `waitForIdle()`, Kakao actions) start
|
||||||
|
timing out *anywhere* data is loading — different test each run. Before concluding a test is flaky or
|
||||||
|
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).
|
||||||
|
|
||||||
### Comment hygiene
|
### Comment hygiene
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,16 +170,17 @@ expanding the sheet over the content, so the next click lands on the wrong eleme
|
||||||
Kakao/Compose-test actions block on Compose reaching *idle* first. A screen that animates forever — an
|
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
|
auto-advancing stories/onboarding carousel, a looping shimmer, a never-ending spinner — never idles, so
|
||||||
`clickWithAssertion()` / `assertIsDisplayed()` on it flake (`… is not displayed`, or
|
`clickWithAssertion()` / `assertIsDisplayed()` on it flake (`… is not displayed`, or
|
||||||
`ComposeNotIdleException`). **Remove the screen at its source rather than out-waiting it:** most are gated
|
`ComposeNotIdleException`). **First rule out a degraded emulator** (see running-and-debugging) — a
|
||||||
by a feature toggle or a mock response — flip it off so the screen never renders. If it's server-driven,
|
slow-*loading* screen on a tired emulator throws the identical exception but is fixed by a cold-boot, not
|
||||||
set the toggle **before app launch** (config is fetched at startup), not mid-test. (Example: the swap
|
by changing the test. Only treat it as a *truly* infinite animation if it reproduces on a fresh emulator.
|
||||||
stories are disabled via their WireMock scenario, then opened with `storiesExist = false`.)
|
|
||||||
|
|
||||||
**Polling the animated node does NOT rescue it** — two false fixes:
|
For a genuinely infinite animation, **remove the screen at its source rather than out-waiting it:** most
|
||||||
- `waitUntilAtLeastOneExists(hasTestTag(TAG))` polls the **merged** tree (no `useUnmergedTree` option); a
|
are gated by a feature toggle or a mock response — flip it off so the screen never renders. If it's
|
||||||
`clickable` node inside a `mergeDescendants` container exists only in the *unmerged* tree → never matches.
|
server-driven, set the toggle **before app launch** (config is fetched at startup), not mid-test.
|
||||||
- `waitUntil { runCatching { node.assertIsDisplayed() }.isSuccess }` reads the unmerged tree but
|
(Example: the swap first-time stories are disabled via their WireMock scenario, then opened with
|
||||||
`assertIsDisplayed` itself blocks on idle, which never comes → the outer wait times out too.
|
`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
|
## Decompose model lifecycle vs. data refresh
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -435,4 +435,35 @@ enum class FeeType {
|
||||||
Fast
|
Fast
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun BaseTestCase.inputAmount(amount: String) {
|
||||||
|
// No waitForIdle(): the transfer screen recalculates the fee continuously and never reaches idle.
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
|
runCatching { onSwapTokenScreen { textInput.assertIsDisplayed() } }.isSuccess
|
||||||
|
}
|
||||||
|
onSwapTokenScreen {
|
||||||
|
textInput.clickWithAssertion()
|
||||||
|
textInput.performTextReplacement(amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeTestRule.waitUntil rather than flakySafely — the latter is unavailable in extensions on BaseTestCase.
|
||||||
|
fun BaseTestCase.assertTransferReady() {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
|
runCatching { onSwapTokenScreen { transferButton.assertIsDisplayed() } }.isSuccess
|
||||||
|
}
|
||||||
|
onSwapTokenScreen { providersBlock.assertIsNotDisplayed() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BaseTestCase.waitForFeeDisplayed() {
|
||||||
|
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
|
runCatching { onSwapTokenScreen { feeAmount.assertIsDisplayed() } }.isSuccess
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun BaseTestCase.swapFeeDiffersFrom(previousFee: String): Boolean {
|
||||||
|
var current = ""
|
||||||
|
onSwapTokenScreen { current = feeAmount.extractText() }
|
||||||
|
return current.isNotEmpty() && current != previousFee
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,35 +27,6 @@ class AppTransfersTest : BaseTestCase() {
|
||||||
private val storiesScenario = "stories_first_time_swap_v2"
|
private val storiesScenario = "stories_first_time_swap_v2"
|
||||||
private val storiesErrorState = "Error"
|
private val storiesErrorState = "Error"
|
||||||
|
|
||||||
private fun BaseTestCase.assertTransferReady() {
|
|
||||||
step("Assert action button label is 'Transfer'") {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
|
||||||
runCatching { onSwapTokenScreen { transferButton.assertIsDisplayed() } }.isSuccess
|
|
||||||
}
|
|
||||||
}
|
|
||||||
step("Assert provider block is not displayed") {
|
|
||||||
onSwapTokenScreen { providersBlock.assertIsNotDisplayed() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun BaseTestCase.inputAmount(amount: String) {
|
|
||||||
step("Input amount '$amount'") {
|
|
||||||
waitForIdle()
|
|
||||||
onSwapTokenScreen {
|
|
||||||
textInput.clickWithAssertion()
|
|
||||||
textInput.performTextReplacement(amount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun BaseTestCase.waitForFeeDisplayed() {
|
|
||||||
step("Assert fee amount is displayed") {
|
|
||||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
|
||||||
runCatching { onSwapTokenScreen { feeAmount.assertIsDisplayed() } }.isSuccess
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@AllureId("9838")
|
@AllureId("9838")
|
||||||
@DisplayName("App transfers: identical pair switches to Transfer mode")
|
@DisplayName("App transfers: identical pair switches to Transfer mode")
|
||||||
@Test
|
@Test
|
||||||
|
|
@ -84,9 +55,9 @@ class AppTransfersTest : BaseTestCase() {
|
||||||
}
|
}
|
||||||
|
|
||||||
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
||||||
inputAmount(amount)
|
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||||
assertTransferReady()
|
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||||
waitForFeeDisplayed()
|
step("Assert network fee is displayed") { waitForFeeDisplayed() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,12 +125,12 @@ class AppTransfersTest : BaseTestCase() {
|
||||||
}
|
}
|
||||||
|
|
||||||
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
||||||
inputAmount(amount)
|
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||||
waitForFeeDisplayed()
|
step("Assert network fee is displayed") { waitForFeeDisplayed() }
|
||||||
step("Click on 'Swap tokens' (reverse) button") {
|
step("Click on 'Swap tokens' (reverse) button") {
|
||||||
onSwapTokenScreen { replaceTokensButton.performClick() }
|
onSwapTokenScreen { replaceTokensButton.performClick() }
|
||||||
}
|
}
|
||||||
assertTransferReady()
|
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,11 +168,11 @@ class AppTransfersTest : BaseTestCase() {
|
||||||
step("Click on 'Max' amount button") {
|
step("Click on 'Max' amount button") {
|
||||||
onSwapTokenScreen { maxAmountButton.performClick() }
|
onSwapTokenScreen { maxAmountButton.performClick() }
|
||||||
}
|
}
|
||||||
waitForFeeDisplayed()
|
step("Assert network fee is displayed") { waitForFeeDisplayed() }
|
||||||
assertTransferReady()
|
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||||
step("Assert 'Transfer' button is enabled") {
|
step("Assert 'Transfer' button is enabled") {
|
||||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
runCatching { onSwapTokenScreen { transferButton.assertIsEnabled() } }.isSuccess
|
onSwapTokenScreen { transferButton.assertIsEnabled() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -235,11 +206,11 @@ class AppTransfersTest : BaseTestCase() {
|
||||||
}
|
}
|
||||||
|
|
||||||
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
||||||
inputAmount(aboveBalanceAmount)
|
step("Enter amount '$aboveBalanceAmount'") { inputAmount(aboveBalanceAmount) }
|
||||||
// Above-balance recalculates the fee forever (Compose never idles), so assert the "Insufficient funds" title, not button state.
|
// Above-balance recalculates the fee forever (Compose never idles), so assert the "Insufficient funds" title, not button state.
|
||||||
step("Assert 'Insufficient funds' is displayed") {
|
step("Assert 'Insufficient funds' is displayed") {
|
||||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
runCatching { onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() } }.isSuccess
|
onSwapTokenScreen { insufficientFundsErrorTitle.assertIsDisplayed() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -273,28 +244,27 @@ class AppTransfersTest : BaseTestCase() {
|
||||||
}
|
}
|
||||||
|
|
||||||
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
||||||
inputAmount(amount)
|
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||||
waitForFeeDisplayed()
|
step("Assert network fee is displayed") { waitForFeeDisplayed() }
|
||||||
assertTransferReady()
|
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||||
|
|
||||||
var marketFee = ""
|
var marketFee = ""
|
||||||
step("Read displayed 'Market' fee amount") {
|
step("Read displayed 'Market' fee amount") {
|
||||||
onSwapTokenScreen { marketFee = feeAmount.extractText() }
|
onSwapTokenScreen { marketFee = feeAmount.extractText() }
|
||||||
}
|
}
|
||||||
step("Open 'Network fee' selector via 'Select fee' icon") {
|
step("Open 'Network fee' selector via 'Select fee' icon") {
|
||||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
runCatching { onSwapTokenScreen { selectFeeIcon.performClick() } }
|
onSwapTokenScreen { selectFeeIcon.performClick() }
|
||||||
runCatching { onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() } }.isSuccess
|
onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.assertIsDisplayed() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
step("Click on 'Fast' fee option") {
|
step("Click on 'Fast' fee option") {
|
||||||
onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.clickWithAssertion() }
|
onSwapSelectNetworkFeeBottomSheet { fastSelectorItem.clickWithAssertion() }
|
||||||
}
|
}
|
||||||
step("Assert fee amount changed from Market fee") {
|
step("Assert fee amount changed from Market fee") {
|
||||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
runCatching {
|
onSwapTokenScreen { feeAmount.assertIsDisplayed() }
|
||||||
onSwapTokenScreen { feeAmount.assertIsDisplayed() }
|
check(swapFeeDiffersFrom(marketFee)) { "Network fee did not change from '$marketFee'" }
|
||||||
}.isSuccess && onSwapTokenScreenFeeDiffers(marketFee)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -336,9 +306,9 @@ class AppTransfersTest : BaseTestCase() {
|
||||||
step("Open Swap in Transfer mode for '$token'") {
|
step("Open Swap in Transfer mode for '$token'") {
|
||||||
openSwapInTransferMode(token, mockContent = Wallet2WithDerivationsMockContent)
|
openSwapInTransferMode(token, mockContent = Wallet2WithDerivationsMockContent)
|
||||||
}
|
}
|
||||||
inputAmount(amount)
|
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||||
assertTransferReady()
|
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||||
waitForFeeDisplayed()
|
step("Assert network fee is displayed") { waitForFeeDisplayed() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -373,9 +343,9 @@ class AppTransfersTest : BaseTestCase() {
|
||||||
}
|
}
|
||||||
|
|
||||||
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
||||||
inputAmount(amount)
|
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||||
assertTransferReady()
|
step("Assert Transfer mode is ready") { assertTransferReady() }
|
||||||
waitForFeeDisplayed()
|
step("Assert network fee is displayed") { waitForFeeDisplayed() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -425,14 +395,12 @@ class AppTransfersTest : BaseTestCase() {
|
||||||
}
|
}
|
||||||
|
|
||||||
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
step("Open Swap in Transfer mode for '$token'") { openSwapInTransferMode(token) }
|
||||||
inputAmount(amount)
|
step("Enter amount '$amount'") { inputAmount(amount) }
|
||||||
step("Assert 'Insufficient $feeCoinName to cover network fee' notification is displayed") {
|
step("Assert 'Insufficient $feeCoinName to cover network fee' notification is displayed") {
|
||||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
runCatching {
|
onSwapTokenScreen {
|
||||||
onSwapTokenScreen {
|
insufficientFeeForTransferNotificationTitle(feeCoinName).assertIsDisplayed()
|
||||||
insufficientFeeForTransferNotificationTitle(feeCoinName).assertIsDisplayed()
|
}
|
||||||
}
|
|
||||||
}.isSuccess
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -477,8 +445,8 @@ class AppTransfersTest : BaseTestCase() {
|
||||||
onSwapSelectTokenScreen { searchBarBlock.performTextReplacement(polygonQuery) }
|
onSwapSelectTokenScreen { searchBarBlock.performTextReplacement(polygonQuery) }
|
||||||
}
|
}
|
||||||
step("Assert '$polygonReceiveName' is displayed") {
|
step("Assert '$polygonReceiveName' is displayed") {
|
||||||
composeTestRule.waitUntil(timeoutMillis = WAIT_UNTIL_TIMEOUT_LONG) {
|
flakySafely(WAIT_UNTIL_TIMEOUT_LONG) {
|
||||||
runCatching { onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsDisplayed() } }.isSuccess
|
onSwapSelectTokenScreen { tokenWithName(polygonReceiveName).assertIsDisplayed() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
step("Assert '$ethereumToken' is not displayed") {
|
step("Assert '$ethereumToken' is not displayed") {
|
||||||
|
|
@ -486,10 +454,4 @@ class AppTransfersTest : BaseTestCase() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun BaseTestCase.onSwapTokenScreenFeeDiffers(previousFee: String): Boolean {
|
|
||||||
var current = ""
|
|
||||||
onSwapTokenScreen { current = feeAmount.extractText() }
|
|
||||||
return current.isNotEmpty() && current != previousFee
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue