9.9 KiB
Building, running, and debugging instrumentation tests
Both APKs matter
Instrumentation tests need TWO APKs:
:app:assembleGoogleMocked→app-google-mocked.apk— production code under test:app:assembleGoogleMockedAndroidTest→app-google-mocked-androidTest.apk— the test code
If you change production code and rebuild only the test APK, the installed main APK stays old and your fix doesn't take effect. Symptom: "the fix doesn't help" — except it does, you just ran the unfixed build.
# Build both
./gradlew :app:assembleGoogleMocked :app:assembleGoogleMockedAndroidTest
# Install each
adb install -r -t <path-to-app-google-mocked.apk>
adb install -r -t <path-to-app-google-mocked-androidTest.apk>
Run a single test (manual)
adb shell pm clear com.tangem.wallet.mocked
curl -X POST http://localhost:8081/__admin/scenarios/reset
adb shell am instrument -w \
-e class "com.tangem.tests.tangempay.TangemPayTest#freezeUnfreezeCard_TogglesCardState" \
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 filemid-run. Run them one at a time (withpm clearbetween) if you must use rawam 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 existingResetCardTestcrashes identically under rawam instrument. These only pass via the orchestrator.
Prefer the orchestrator (it's what CI/Marathon use). Run a class or method through Gradle:
./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:
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:8081withadb reverse tcp:8081 tcp:8081run first.
Pass it through the orchestrator (recommended):
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:
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.
The app's own log lives in files/log.txt, NOT logcat
The mocked build routes TangemLogger to a file via FileLogWriter, so adb logcat | grep …
finds nothing of the app's own logs. When a screen fails silently — fee shows "—", a banner never
appears, an action button stays disabled — and WireMock shows everything matched, the real reason is
almost always in the app log:
adb exec-out run-as com.tangem.wallet.mocked cat files/log.txt | grep -iE "loadFee|getFee|No native|Error|DataError"
This is the single fastest way to find app-side root causes. It's what pinpointed a transfer-fee failure
to loadFee[transfer]: DataError(... IllegalStateException: No native currency found ...) — i.e. a
missing native coin in the mock, invisible from the UI and from the WireMock journal alone.
WireMock journal: "mock missing" vs "the app never asked"
/__admin/requests/unmatched finds missing mappings. But when a feature silently doesn't happen (a fee
that never computes, a banner that never shows), also inspect the full request log — the app may not
be issuing the request at all (an app-side data gate), which is a different problem than a missing mock
and is NOT fixable by adding mappings:
curl -s "http://localhost:8081/__admin/requests?limit=500" \
| jq -r '.requests[].request | "\(.method) \(.url)"' | sort -u
# For RPC providers, also break down by method:
curl -s "http://localhost:8081/__admin/requests?limit=500" \
| jq -r '.requests[].request.body' | grep -oE '"method":"[^"]+"' | sort | uniq -c
unmatched=0 and the expected request absent → the app never asked (data/state gate, e.g. a coin
missing from the portfolio) → fix the mock data or app, not the mappings.
"UiAutomationService already registered" = back-to-back runs; retry
Rapid consecutive am instrument invocations sometimes fail instantly with
IllegalStateException: UiAutomationService … already registered!. It's an instrumentation-teardown
race between runs, not a test failure — just retry. (The orchestrator/CI spaces runs out and avoids it.)
When scripting many manual runs, retry-on-this-string rather than counting it as a failure.
Classify the result — Allure noise vs. real failure
After pm clear, /data/user/0/<pkg>/files/original_screenshots doesn't exist →
AllureResultsHack.testRunFinished throws NoSuchFileException → reported as
Tests run: 1, Failures: 1 with a stack trace starting at AllureResultsHack. This is a post-run
hook failure, NOT a test logic failure.
Distinguish:
- First stack frame is
AllureResultsHack.testRunFinished→ infra hook noise; ignore it. - Kaspresso step logs show all
SUCCEEDfor steps 1..N → the test passed. - A REAL failure shows
java.lang.AssertionErrorinside the test's own classes (e.g.at com.tangem.tests.X.foo$lambda…). When auto-classifying CLI output, key off the presence ofjava.lang.AssertionErrorvs. onlyoriginal_screenshots.
@Ignore on instrumentation tests
- Pattern:
@Ignore("https://tangem.atlassian.net/browse/AND-XXXXX")above@Test. - When ignored,
am instrument -e class …reportsOK (0 tests)withTests run: 0(NOTSkipped: 1). Auto-detection should match the zero-test count.
WireMock cheatsheet
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.
# Set a scenario state — PUT, not POST
curl -X PUT http://localhost:8081/__admin/scenarios/<name>/state \
-H "Content-Type: application/json" -d '{"state":"<state>"}'
# Reset all scenarios
curl -X POST http://localhost:8081/__admin/scenarios/reset
# Inspect
curl http://localhost:8081/__admin/mappings | jq
curl http://localhost:8081/__admin/scenarios | jq '.scenarios[] | {name, state}'
- Mocks repo: default to the sibling directory
../tangem-api-mocks/(i.e. next totangem-app-android). If that path doesn't exist, ask the user where the mocks repo is rather than guessing. - The repo is branch-per-suite — dozens of feature branches (e.g.
send-via-swap-p1,account-creation,swap-express-mocks,android-tangem-pay-mocks). There is no universal default branch; check out the one the suite under test expects. If it's unclear which branch holds the mappings for your flow, ask the user. Mappings live undermocks/mappings/, response bodies undermocks/__files/. - State transitions are atomic per
requiredScenarioState. If a scenario defines anAfterDepositmapping for/customer/balancebut not/customer/me, a request to/customer/meafter switching toAfterDepositfalls through. Check both endpoints when an "after" assertion fails.
Misc
./gradlew unitTestaggregates all debug/googleDebug + JVM-module tests — faster than per-module tasks for verifying a broad change (but it's for unit tests, not instrumentation).- Detekt config lives in the
tangem-android-toolsgit submodule — look there before assuming a local.detekt.yml. - Path discipline: stay at the repo root;
cdinto the mocks repo only when needed and prefer absolute paths (the shell session resets cwd).