Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-03 17:04:46 +02:00
parent 9b0305efe9
commit 2453d63633
12 changed files with 702 additions and 5 deletions

View file

@ -71,8 +71,9 @@ When the user asks to **port** an iOS test to Android:
strings inside `step(...)`.
- **Each click is its own** `step("Click on '$x' button")`. Combining clicks into one step hides which
click failed in the Allure report.
- **Step naming**: `Click on 'X' button` (not "Tap X"); `Assert <thing> is displayed` (not "Check X
visible"). Keep it consistent with the existing suite.
- **Step naming**: `Click on 'X' button` (not "Tap X"); `Assert <thing> is displayed` / `is not displayed`.
Reviewers reject `is visible`, `does not exist`, `Check X visible` — the convention is **`is displayed` /
`is not displayed`** even though older tests in the file may still use the old phrasing (don't copy it).
- **No conditional `if (foo.isDisplayedSafely()) foo.performClick()`** for elements that are
deterministically present after `pm clear` — the `if` is dead code. Use a straight `performClick()`.
@ -131,5 +132,6 @@ Delete anything explaining WHAT a step does.
- **`reference/compose-traps.md`** — read when the screen uses `PullToRefreshBox`,
`TangemHoldToConfirmButton`, a Decompose model that fetches in `init {}`, or a hot-wallet import with
an access code. These have silent failure modes that look like passing tests.
- **`reference/running-and-debugging.md`** — read when building, installing, running a single test,
interpreting CLI/Allure output, using `@Ignore`, or driving WireMock scenarios.
- **`reference/running-and-debugging.md`** — read when building, installing, running tests (orchestrator
vs. raw `am instrument`), running against a local WireMock, interpreting CLI/Allure output, using
`@Ignore`, or driving WireMock scenarios.

View file

@ -41,6 +41,30 @@ so the hold gesture is silently swallowed: the button looks fine, the user holds
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

View file

@ -29,6 +29,77 @@ adb shell am instrument -w \
com.tangem.wallet.mocked.test/com.tangem.common.HiltTestRunner
```
## Harness: orchestrator vs. raw `am instrument`
The app is configured `execution = "ANDROIDX_TEST_ORCHESTRATOR"` (`app/build.gradle.kts`). The orchestrator
runs **each test method in its own process** (and can clear app data between them). It is still 100%
local — it runs on the same emulator; nothing remote about it.
Raw `adb shell am instrument` runs **all selected tests in one shared process**, which has two failure
modes that look like test bugs but aren't:
- Running several tests in one invocation → `IllegalStateException: There are multiple DataStores active
for the same file` mid-run. Run them one at a time (with `pm clear` between) if you must use raw
`am instrument`.
- Tests that re-scan the card inside **Card/Device Settings** (the "Scan card or ring" gate) →
`IllegalStateException: Tangem SDK is null after re-registering with foreground activity`. The existing
`ResetCardTest` crashes identically under raw `am instrument`. These only pass via the orchestrator.
**Prefer the orchestrator** (it's what CI/Marathon use). Run a class or method through Gradle:
```bash
./gradlew :app:connectedGoogleMockedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.tangem.tests.DetailsTest
# or a single method: ...class=com.tangem.tests.DetailsTest#someTest
# or several classes: ...class=com.tangem.tests.DetailsTest,com.tangem.tests.SecurityModeTest
```
Gradle installs both APKs, runs via the orchestrator, then **uninstalls them** — so a following raw
`am instrument` reports `Unable to find instrumentation info`; reinstall both APKs first. Read results
from the JUnit XML (authoritative pass/fail counts), not just stdout:
```bash
ls -t app/build/outputs/androidTest-results/connected/mocked/flavors/google/*.xml | head -1
# inspect tests="…" failures="…" errors="…" skipped="…" and the <testcase>/<failure> nodes
```
## Running against local WireMock
Every instrumentation test runs with `ApiEnvironment.MOCK` (forced in `BaseTestCase.setupHooks`), so the
app's API base URLs point at `wiremock.tests-d.com` — i.e. tests **always** talk to WireMock, never the
real backend. By default that's the **remote** WireMock at `wiremock.tests-d.com`. To use a **local**
WireMock instead, pass `wiremockBaseUrl`: `WireMockRedirectInterceptor` then rewrites every
`wiremock.tests-d.com` request to your local instance.
Emulator addressing matters — `localhost` inside an emulator is the **emulator itself**, not your host:
- Use the host alias **`http://10.0.2.2:8081`** (no extra setup), **or**
- `http://localhost:8081` **with** `adb reverse tcp:8081 tcp:8081` run first.
Pass it through the orchestrator (recommended):
```bash
curl -s -X POST http://localhost:8081/__admin/scenarios/reset # start clean
./gradlew :app:connectedGoogleMockedAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.tangem.tests.DetailsTest \
-Pandroid.testInstrumentationRunnerArguments.wiremockBaseUrl=http://10.0.2.2:8081
```
(Raw `am instrument` equivalent: `-e wiremockBaseUrl http://10.0.2.2:8081` — subject to the harness
caveats above.)
**If a screen hangs / you get `ComposeNotIdleException` (infinite recomposition):** that usually means a
request the app made wasn't served (endless retry/loading), *not* a test bug. Ask WireMock what it
didn't match — this is the smoking gun:
```bash
curl -s http://localhost:8081/__admin/requests/unmatched | jq '.requests[] | "\(.method) \(.url)"'
```
`unmatched: 0` means the URL plumbing is correct and local WireMock served everything — look elsewhere
(harness/emulator) for the hang. A non-empty list names exactly which mapping (or scenario state) the
local instance is missing.
## Classify the result — Allure noise vs. real failure
After `pm clear`, `/data/user/0/<pkg>/files/original_screenshots` doesn't exist →
@ -51,7 +122,8 @@ Distinguish:
## WireMock cheatsheet
Local override is detected; otherwise hits remote. Default local port: `8081`.
Without a `wiremockBaseUrl` arg the app hits the **remote** WireMock (`wiremock.tests-d.com`); pass the
arg to redirect to a local instance (see "Running against local WireMock"). Default local port: `8081`.
```bash
# Set a scenario state — PUT, not POST