Mobile E2E Testing skill for Claude Code
Mobile UI tests that pass ten runs out of ten. Claude picks one end-to-end framework per app stack, adds stable test IDs and a launch-argument test mode that fixes backend, locale and animations, and writes journeys that reset state and wait on conditions rather than sleeping. It sets up emulator and simulator CI with artifacts on failure, uses device farms for real-device passes, and works through a flake checklist until runs are repeatable.
- name
mobile-e2e-testing - size275 lines · ≈2,950 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Builds reliable end-to-end UI tests for iOS and Android apps with Maestro, Detox, XCUITest, and Espresso or Compose UI tests — choosing the framework per stack, stable test IDs (accessibilityIdentifier, testTag, testID), launch arguments for deterministic state, permission and system-dialog handling, synchronization instead of sleeps, emulator and simulator setup in CI, and device farms such as Firebase Test Lab. Use when adding mobile E2E tests, when UI tests are flaky or slow, when choosing between Maestro and Detox, or when running mobile tests in CI or on real devices. Try asking
- Add Maestro tests for login and checkout in our React Native app
- Our XCUITests fail randomly in CI on permission dialogs. Make them stable
- Should we use Detox or Maestro for this Expo app? Set up the one you pick
Use it when
- Adding end-to-end UI tests to an iOS, Android, React Native or Flutter app
- Mobile UI tests are flaky, slow or tied to coordinates and text
- Choosing between Maestro, Detox, XCUITest and Espresso
- Running mobile tests on CI emulators or a device farm
Skip it when
- You need build, signing and store upload automation: use fastlane-ci
- Testing a web app in mobile browsers: use playwright-e2e
How to check the result
Each new test passes ten consecutive local runs and two CI runs on the same commit, and a deliberately broken journey fails at the right step with video or screenshots attached.
The common mistake it prevents
Letting tests share app state and hit a live shared backend, so failures depend on run order and whatever data another test left behind.
A mobile E2E suite earns its cost by covering a handful of critical journeys (sign-in, onboarding, the core transaction, purchase, deep link entry) on every PR, deterministically. Done well means: elements are found by stable IDs, the app launches into a known state via launch arguments, the backend is controlled, nothing sleeps, and every failure leaves a video, screenshot, and device log.
Choose the framework
| App stack | Default | Alternative | Notes |
|---|---|---|---|
| React Native (Expo or bare) | Maestro | Detox | Maestro needs no app code changes; Detox gives gray-box sync with the JS thread |
| Flutter | Maestro (reads semantics) | integration_test + Patrol | Add Semantics(identifier:)/labels for stable lookup |
| Native iOS (SwiftUI/UIKit) | XCUITest | Maestro for cross-platform flows | XCUITest ships with Xcode; best system-alert handling |
| Native Android (Compose/Views) | Compose UI test / Espresso | Maestro, UI Automator for cross-app | Instrumented tests run in-process with idling sync |
| Same flows on both platforms, small team | Maestro | — | One YAML flow per journey, platform branches where needed |
Do not run two E2E frameworks for the same journeys. Native unit/UI tests for screen logic, one E2E framework for journeys.
Ground rules
- Every interactive element in a tested journey has a stable ID:
accessibilityIdentifier(iOS),Modifier.testTag+testTagsAsResourceId(Compose),testID(React Native),Semantics(identifier:)or keys (Flutter). Never locate by coordinates or by translated text, unless the text is the thing being tested. - The app under test reads a launch argument or environment flag (
-uitesting,E2E=1) that: points at a test backend or mock server, disables animations, skips onboarding/what’s-new, disables analytics and remote config, fixes locale and timezone. - Each test starts from a clean state (
clearState,newInstance, OrchestratorclearPackageData). Shared state across tests is the top flake source. - Pre-grant permissions in setup; handle the remaining system dialogs explicitly.
- No fixed sleeps. Use the framework’s waiting (
extendedWaitUntil,waitFor,waitForExistence, idling resources,waitUntilin Compose). - Retries: at most one in CI, and every retried pass is logged for follow-up.
Workflow
- [ ] 1. Inventory: stack, existing UI tests, IDs on key screens, CI runners
- [ ] 2. Pick 5–10 journeys; list the screens and elements each touches
- [ ] 3. Add stable IDs and the E2E launch mode to the app
- [ ] 4. Control the backend: seeded test environment or mock server
- [ ] 5. Write flows/tests with explicit waits and state reset
- [ ] 6. Run locally 10x on simulator/emulator; fix every intermittent failure
- [ ] 7. CI: build once, run on emulator/simulator per PR; device farm nightly
Maestro
# .maestro/login.yaml
appId: com.example.app
tags: [smoke]
env:
EMAIL: e2e+${MAESTRO_RUN_ID}@example.com
---
- launchApp:
clearState: true
arguments:
uitesting: "true"
- tapOn:
id: "login.email"
- inputText: ${EMAIL}
- tapOn:
id: "login.password"
- inputText: ${E2E_PASSWORD}
- tapOn:
id: "login.submit"
- extendedWaitUntil:
visible:
id: "home.header"
timeout: 15000
- assertNotVisible: "Something went wrong"
maestro test .maestro/ --include-tags smoke -e E2E_PASSWORD="$E2E_PASSWORD" \
--format junit --output build/maestro-junit.xml
maestro studio # inspect hierarchy and IDs interactively
- Assertions retry for a few seconds; use
extendedWaitUntilfor slow screens, notwaitForAnimationToEndchains. - Factor shared steps into subflows with
runFlow: subflows/login.yaml. - Use
when: { platform: iOS }conditions for platform-specific steps sparingly. - Check flags against
maestro test --helpfor the installed version; the CLI evolves.
Detox (React Native)
// e2e/login.test.js
describe('login', () => {
beforeEach(async () => {
await device.launchApp({
newInstance: true,
delete: true, // fresh install state
permissions: { notifications: 'YES' },
launchArgs: { uitesting: 'true' },
});
});
it('signs in', async () => {
await element(by.id('login.email')).typeText('[email protected]');
await element(by.id('login.password')).typeText(process.env.E2E_PASSWORD);
await element(by.id('login.submit')).tap();
await waitFor(element(by.id('home.header'))).toBeVisible().withTimeout(15000);
});
});
npx detox build --configuration ios.sim.release
npx detox test --configuration ios.sim.release --retries 1 \
--record-videos failing --take-screenshots failing --record-logs failing
- Detox waits for the app to go idle (network, timers, animations). Endless animations
or polling keep it busy forever: stop them in E2E mode, or scope
device.disableSynchronization()around the step and wait explicitly. - Test release builds in CI (
*.releaseconfigs); debug builds load JS from Metro and are slower and flakier. - Detox 20.x supports the New Architecture on both platforms; keep Detox current with the React Native version and check its compatibility notes on RN upgrades.
XCUITest (iOS)
final class LoginUITests: XCTestCase {
let app = XCUIApplication()
override func setUp() {
continueAfterFailure = false
app.launchArguments += ["-uitesting", "-AppleLanguages", "(en)", "-AppleLocale", "en_US"]
app.launchEnvironment["API_BASE_URL"] = "http://localhost:8089"
app.launch()
}
func testSignIn() {
app.textFields["login.email"].tap()
app.textFields["login.email"].typeText("[email protected]")
app.secureTextFields["login.password"].tap()
app.secureTextFields["login.password"].typeText(ProcessInfo.processInfo.environment["E2E_PASSWORD"] ?? "")
app.buttons["login.submit"].tap()
XCTAssertTrue(app.staticTexts["home.header"].waitForExistence(timeout: 15))
}
}
xcrun simctl list devices available | grep iPhone # pick an installed simulator
xcodebuild build-for-testing -scheme App -destination 'platform=iOS Simulator,name=iPhone 17' \
-derivedDataPath build/dd
xcodebuild test-without-building -scheme App -destination 'platform=iOS Simulator,name=iPhone 17' \
-derivedDataPath build/dd -resultBundlePath build/UITests.xcresult \
-parallel-testing-enabled YES -retry-tests-on-failure -test-iterations 2
xcrun simctl privacy booted grant photos com.example.app # pre-grant a permission
- System alerts: pre-grant with
simctl privacy, or tap throughXCUIApplication(bundleIdentifier: "com.apple.springboard").alerts.buttons["Allow"].addUIInterruptionMonitoronly fires on the next interaction and is unreliable as the sole strategy. - The app reads
-uitestingviaProcessInfo.processInfo.arguments.contains("-uitesting")and swaps in test dependencies at the composition root. - Inspect failures in the
.xcresultbundle (open in Xcode, orxcrun xcresulttool); attach screenshots withXCTAttachmentat key steps.
Espresso and Compose (Android)
@get:Rule val compose = createAndroidComposeRule<MainActivity>()
@Test fun signIn() {
compose.onNodeWithTag("login.email").performTextInput("[email protected]")
compose.onNodeWithTag("login.password").performTextInput(BuildConfig.E2E_PASSWORD)
compose.onNodeWithTag("login.submit").performClick()
compose.waitUntil(timeoutMillis = 15_000) {
compose.onAllNodesWithTag("home.header").fetchSemanticsNodes().isNotEmpty()
}
}
// app/build.gradle.kts
android {
defaultConfig {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
testOptions {
execution = "ANDROIDX_TEST_ORCHESTRATOR"
animationsDisabled = true
}
}
dependencies { androidTestUtil("androidx.test:orchestrator:<current>") }
./gradlew connectedDebugAndroidTest # attached emulator/device
adb shell pm grant com.example.app android.permission.POST_NOTIFICATIONS
- Register
IdlingResources (or use Compose’s built-in idling with coroutines on the main dispatcher) for background work Espresso cannot see. - Set
Modifier.semantics { testTagsAsResourceId = true }at the root so Maestro and UI Automator can see Compose test tags as resource IDs. - Gradle Managed Devices define emulators in the build for reproducible local and CI runs.
Backend control
| Option | Use when |
|---|---|
| Mock server (WireMock, MockServer, Prism from OpenAPI) on the CI host | PR runs; deterministic error states |
| Dedicated E2E environment with seeded accounts per run | Nightly; real integration |
| In-app fake repositories behind the E2E flag | Native UI tests needing extreme speed |
Never point E2E tests at production. Emulators reach the host at 10.0.2.2; iOS
simulators share the host’s localhost.
CI and device farms
- Build once per run (
build-for-testing,assembleDebug assembleDebugAndroidTest,detox build) and reuse the artifact across shards. - Android on Linux runners needs KVM; the
reactivecircus/android-emulator-runneraction handles boot and snapshotting. iOS needs macOS runners; boot the simulator before tests to avoid first-launch timeouts. - Shard by test class or flow file across jobs; keep each shard under ~10 minutes.
- Upload on failure: videos, screenshots,
.xcresult,adb logcatdump, Maestro/Detox artifacts directories. - Real-device coverage nightly or pre-release on a farm: Firebase Test Lab
(
gcloud firebase test android run --type instrumentation --app app.apk --test test.apk --device model=<id>,version=<api>; iOS via XCTest zip), AWS Device Farm, BrowserStack, or Maestro’s cloud. Choose 3–5 devices covering oldest supported OS, newest OS, a small screen, and a low-end Android.
Flake checklist
| Symptom | Cause | Fix |
|---|---|---|
| Element not found intermittently | Animation or async load | Disable animations; explicit wait on the target |
| Passes locally, fails in CI | Slower emulator, cold boot | Boot and warm up before tests; increase only the one wait |
| Fails after another test | Leftover login, DB, keychain | Clear state per test; Orchestrator; delete: true |
| Random permission dialog | Not pre-granted | simctl privacy, pm grant, Detox permissions |
| Keyboard covers button | Soft keyboard | closeSoftKeyboard(), hideKeyboard, or scroll |
| Different text | Locale/time | Fix locale/timezone via launch args |
| Detox hangs | App never idle | Stop looping animations/polling in E2E mode |
Gotchas
- Release-like builds behave differently (minification/R8, ProGuard stripping test IDs, disabled debug menus); run E2E against a build type close to release with test IDs kept.
accessibilityIdentifieris not read by VoiceOver; do not replace accessibility labels with IDs. Keep both.- Real purchase, biometric and push flows need sandbox accounts or OS simulation (Face ID
enrollment menu in Simulator,
simctl push); isolate them in a tagged suite. - Simulator and emulator images update with Xcode/SDK releases; pin images in CI and update deliberately.
Verification
- Run each new test 10 times in a row locally; 10/10 green.
- Run the suite in CI twice on the same commit; identical results.
- Break the journey (wrong password, 500 from mock): the test fails at the right step with a screenshot or video showing it.
- Confirm artifacts (video/screenshots/logs) are attached to the failed CI job.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.