18 KiB
| name | description | allowed-tools | argument-hint | ||
|---|---|---|---|---|---|
| write-ui-test | Write a Kaspresso/Compose instrumentation UI test for the Tangem Android app following project conventions — test class shape, page-object locations, Allure step naming, WireMock scenario setup, synchronization, and meaningful assertions. Covers known Compose traps (PullToRefreshBox swipe, TangemHoldToConfirmButton, Decompose lifecycle, hot-wallet access code) and the build/run/debug flow. Use when the user asks to write, add, port, or fix an instrumentation / androidTest / UI test, a page object, or a test scenario ("напиши UI-тест", "добавь инструментальный тест", "напиши тест в androidTest", "page object", "автотест на экран"). | Read, Grep, Glob, Bash, Edit, Write, Agent |
|
Write an instrumentation (androidTest) UI test for the Tangem Android app. These conventions are enforced by reviewers (tnagmetulla, dpodoynikov) — applying them up front skips a review round.
This is an interactive skill: if scope is ambiguous (which screen, which flow, what the final assertion should verify), ask before writing. Do not invent UI text or test tags — read the real production source and reuse existing patterns.
When to use
Use for instrumented UI tests under app/src/androidTest/ (Kaspresso + Kakao-Compose), page objects,
and test scenarios. Not for JVM/Robolectric unit tests (testDebugUnitTest) — those follow a
different setup.
Workflow
- Clarify scope. Which screen/flow, which Allure TC#, and what the final assertion verifies. Ask if any of these is unclear.
- Find an existing sibling test to mirror. Grep
app/src/androidTest/for a test on a similar screen (e.g.SendViaSwapTest). Match its structure rather than inventing one. Read the real production composable to get the actualtestTags and string resources — never guess UI text. - Locate / extend page objects in
com/tangem/screens/(see Locations). Add new ones there, never inside the scenario or test file. - Set up WireMock scenarios in the test body if the flow depends on backend state
(see
reference/running-and-debugging.md). - Write the test per Conventions below.
- Build BOTH APKs, install, run, and classify the result correctly — Allure post-run hook
failures are not test failures (see
reference/running-and-debugging.md). - Final cleanup pass — remove what you no longer use. Before declaring done, review every file you touched for leftovers from iteration (see "Final cleanup" below). This is a required step, not optional.
Final cleanup (required before done)
Iterating on a test typically leaves dead code behind — an import for a helper you swapped out, an
@OptIn for an API you stopped calling directly, a matcher you replaced. Reviewers flag these, and a
stray @OptIn reads as "this code needs an experimental API" when it doesn't. For every file you
added or edited (production testTag files included), check and remove:
- Unused imports. Any leftover after swapping an approach (e.g.
performTextReplacement→performTextInputInChunks,onDialogafter extracting a scenario). Diff-check each import against the body:for f in <changed .kt>; do grep '^import' "$f" | while read -r i; do n=${i##*.}; n=${n%% *}; grep -q "\b$n\b" <(grep -v '^import' "$f") || echo "$f: unused? $i"; done; done(heuristic —foundation.layout.*wildcards andgetValue/setValueused only bybydelegates are false positives; verify before deleting). - Redundant
@OptIn(ExperimentalTestApi::class). Needed only where you call an experimental API directly (performScrollToNode,waitUntilAtLeastOneExists,waitUntilDoesNotExist, …). It is not needed just to call your own helper that is already annotated (e.g. a page-objectscrollToNetworkthat wrapsperformScrollToNode), nor for the stablecomposeTestRule.waitUntil(timeoutMillis, condition). ABaseTestCase-extension scenario that only calls annotated page-object methods + stablewaitUntilneeds no@OptIn— drop it (and itsimport androidx.compose.ui.test.ExperimentalTestApi). - Dead vals / matchers / page-object members you introduced and then stopped referencing.
Then recompile the changed module(s) to confirm the removals are valid — the androidTest APK
(:app:assembleGoogleMockedAndroidTest) for test-side edits, or the touched production module
(e.g. :core:ui:compileDebugKotlin) for testTag edits. A clean compile with no opt-in / unused-symbol
warnings is the pass criterion. Behavior-only-neutral cleanups don't need a re-run of the suite.
Porting a test from iOS
When the user asks to port an iOS test to Android:
- Default to the sibling iOS repo
../tangem-app-ios/(next totangem-app-android). If that path doesn't exist, ask the user where the iOS repo is — don't guess. - iOS UI tests live under
TangemUITests/; look there for the source test, its page objects (*Screen), and accessibility identifiers (*AccessibilityIdentifiers). - Port the intent and steps, not the API. Map the iOS stack to the Android one:
XCUITest/accessibility identifiers → Compose
testTag; iOS*Screenpage objects → Kotlin page objects incom/tangem/screens/; XCTest assertions → Kaspresso/Truth assertions. Re-derive the real AndroidtestTags and string resources from production source — never reuse iOS identifier strings. - Card/wallet mock mapping — where iOS uses
wallet2, Android uses the defaultWallet(openMainScreen()with noproductType→ProductType.Wallet). Do NOT port iOS.wallet2toProductType.Wallet2. Other cards map directly: iOS.twin→ProductType.Twins,.xrpNote→ProductType.Note,.four12→Firmware412MockContent(via themockContentparam).ProductType.Wallet2exists but is a distinct Wallet-2.0-card case, not the iOS-wallet2analog. - A wallet has no balance until you sync. The default
Walletmock starts with missing derivations ("Some addresses are missing"); the fiat balance shows—until you callsynchronizeAddresses()afteropenMainScreen()(mirrorTotalBalanceUpdateTest). Any test asserting a balance/fiat-equivalent must sync first, thenwaitUntilthe value loads — balances re-load asynchronously (e.g. after an app-currency change the equivalent repaints with a delay). - The WireMock scenarios are usually shared across platforms, but the branch may differ
(see
reference/running-and-debugging.md).
Conventions (must-follow)
Test class shape
- Test method names are camelCase and always end with
Test(e.g.groupTokensTest(),renameWalletTest()). Never usesnake_caseand never the unit-testGIVEN … WHEN … THEN …backtick phrasing — that GWT convention is for JVM unit tests only, not instrumentation tests. - Scenario state setup goes in the test body, not inside the open-the-feature helper. Each test
starts with explicit
step("Set WireMock scenario '$name' to '$state'") { setWireMockScenarioState(name, state) }calls, then calls a thin helper (e.g.openTangemPay()) that only opens the screen. Mirror theSendViaSwapTestpattern. - Open-the-feature helpers stay thin — no scenarios-as-parameters, no scenario juggling inside.
- Every scenario name + state is a
valat the top of the test method. Reviewers reject magic strings insidestep(...). - Each click is its own
step("Click on '$x' button"). Combining clicks into one step hides which 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 ascenarios/file as aBaseTestCaseextension, not as a private method on the test class — reviewers reject the latter. The test body then calls it wrapped in astep(...)like any scenario. - Every scenario call in the test body is wrapped in its own
step("…"), even though the scenario itself contains innerstep(...)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 scenario extension functions; onlyflakySafelyis restricted to theTestCasebody. Caveat: don't wrap a mutating scenario (e.g. one that long-clicks to sign+send) inflakySafely— a retry would re-fire the action; rely on the assertion's own built-in retry instead. - Step naming:
Click on 'X' button(not "Tap X");Assert <thing> is displayed/is not displayed. Reviewers rejectis visible,does not exist,Check X visible— the convention isis displayed/is not displayedeven 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 afterpm clear— theifis dead code. Use a straightperformClick().
Locations
| What | Where |
|---|---|
| Page objects | app/src/androidTest/kotlin/com/tangem/screens/… — always |
| Common test helpers | app/src/androidTest/kotlin/com/tangem/common/utils/ |
| Feature scenarios | app/src/androidTest/kotlin/com/tangem/scenarios/ |
Cross-feature helper (e.g. confirmSwapByHolding) |
the feature-of-origin scenarios file (e.g. SwapScenarios.kt), not the consumer's |
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 mapsNodeMatcher.position→onAllNodes(matcher)[index]for you). Do not hand-rollsemanticsProvider.onAllNodes(matcher)[index]. - Scroll to index / matcher / key → inside a KNode block:
knode { performScrollToIndex(index) },knode { performScrollToNode(matcher) },knode { performScrollToKey(key) }(mirrorMarketsPageObject.scrollToListedOnBlock). These wrappers are@ExperimentalTestApi, so annotate the page-object method@OptIn(ExperimentalTestApi::class)— that opt-in is expected, not a smell. - Relationship filters →
ViewBuilderDSL insidechild { }:hasAnyChild,hasAnySibling,hasAnyAncestor,hasAnyDescendant,addSemanticsMatcher(matcher),useUnmergedTree = true. - Lazy list / pager item (esp. below the fold) →
KLazyListNode+childWith { … }/childAt(index)(seeAddFundsBottomSheetPageObject,BuyTokenPageObject), not a manual scroll +onAllNodes. - A raw Compose-Test op with no KNode wrapper (e.g.
captureToImage(), and any otherSemanticsNodeInteractionextension Kakao doesn't surface) → do not fall back tocomposeTestRule.onNode(hasTestTag(...))in the scenario. Every KNode exposes a publicdelegate, and the built-in actions/assertions are all justdelegate.perform(type) { <this: SemanticsNodeInteraction> }/delegate.check(type) { … }.ComposeOperationTypeis an open interface, so declare a tiny privateenum class Xxx : ComposeOperationType { … }and reach the underlyingSemanticsNodeInteractionfrom a page-object method — reusing an existing KNode (its testTag +useUnmergedTree). Capture a return value via alateinit varwritten inside the lambda. Example (TokenReceiveQrCodeBottomSheetPageObject.captureQrCodeBitmap):
This keepsfun captureQrCodeBitmap(): Bitmap { lateinit var bitmap: Bitmap qrCode.delegate.perform(QrCodeAction.CAPTURE) { bitmap = captureToImage().asAndroidBitmap() } return bitmap } private enum class QrCodeAction : ComposeOperationType { CAPTURE }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 byMainScreenPageObject), 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)fromcom.tangem.core.res.Rorcom.tangem.core.ui.R. The Detekt ruleUnsafeStringResourceUsageenforces this for production code; reviewers extend it to test code informally.
Allure IDs
- Every test method gets its own unique
@AllureId. Never reuse the same id across two test methods — not even for two variants of one manual case. If a manual case is split into multiple automated tests (e.g. a positive and a negative variant), each test must be linked to its own distinct Allure case/id. (Note: iOS sometimes shares one id across methods — do NOT mirror that here.)
Assertions
- Never use Kotlin's built-in
assert(...)— Android instrumentation runs don't enable JVM assertions, soassert(false)is a silent no-op. Use Truth / JUnit / Kaspresso / Kakao assertions. - Clipboard checks:
assertClipboardTextEquals(expected, context)fromcommon/utils/ClipboardUtils.kt. Read displayed text viaKNode.extractText()first if you need to compare against UI state. - Every test ends with a meaningful assertion, not just an action. A test whose last step is "Click Submit" without verifying the result gets rejected.
Waits and synchronization
- Manual polls are banned (
onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty()in a loop) — even if a bot reviewer suggests one. - Default in the test body:
flakySafely(TIMEOUT) { assertion }— the codebase idiom (hundreds of uses); reviewers prefer it overcomposeTestRule.waitUntil { runCatching { … }.isSuccess }. ComposeNotIdleException/AppNotIdleException("busy for ~60s") is usually a sick emulator, not your test. After many back-to-back local runs the emulator degrades (you may even see a "System UI 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,flakySafelyis NOT available regardless — useBaseTestCase.awaitSuccess(timeoutMillis = WAIT_UNTIL_TIMEOUT) { … }(a shared member onBaseTestCase, no import needed) which wrapscomposeTestRule.waitUntil { runCatching(block).isSuccess }. Each async step readsawaitSuccess { onXxxScreen { field.assertExists() } }before the action. For appearance-only waitscomposeTestRule.waitUntilAtLeastOneExists(matcher, timeout)(or{ a exists || b exists }for either/or) is also fine. Do not re-declare a privateawaitSuccessin a scenario file — the sharedBaseTestCase.awaitSuccessalready exists; older files may still have a private copy, don't copy that pattern. - Right-size the timeout — don't stamp
WAIT_UNTIL_TIMEOUT_LONGon every step. The timeout is a ceiling, not a sleep (waitUntilreturns the moment the condition holds), but the defaultWAIT_UNTIL_TIMEOUT(20 s) already dwarfs a normal async transition. Reserve…_LONG/…_VERY_LONGfor steps that are genuinely slow (a real network round-trip + debounce that can approach 20 s); using LONG uniformly is lazy and hides which step is actually the slow one.
Comment hygiene
This repo enforces "no comments unless WHY is non-obvious", in test code too. One line max, WHY-only —
encode a hidden constraint, not what the code does. Example that earns its keep:
// Create+confirm screens share ACCESS_CODE_INPUT — gate on confirm-screen title.
Delete anything explaining WHAT a step does.
Reference docs
reference/compose-traps.md— read when the screen usesPullToRefreshBox,TangemHoldToConfirmButton, a Decompose model that fetches ininit {}, a hot-wallet import with an access code, or a target inside a LazyColumn/LazyRow that may be below the fold (use aKLazyListNodematcher that auto-scrolls — never a manual swipe). These have silent failure modes that look like passing tests.reference/running-and-debugging.md— read when building, installing, running tests (orchestrator vs. rawam instrument), running against a local WireMock, interpreting CLI/Allure output, using@Ignore, or driving WireMock scenarios. Includes how to find app-side root causes when the UI fails silently (the app log infiles/log.txt, and the WireMock journal).reference/yield-mode.md— read before writing any Yield Mode (yield-supply / "Earning") test. Covers the hot-wallet activation flow, why Ethereum (not Polygon — gasless), the full mock set + scenarios, theisActive-semantics / lowercase-address / hold-timing / Pill-testTag gotchas, and the production testTags already added. Mirrortests/yield/YieldModeTest.kt.