Testing No. 134

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.

  • namemobile-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

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.

~/.claude/skills/mobile-e2e-testing/SKILL.md

--- name: mobile-e2e-testing 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… license: MIT ---

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 stackDefaultAlternativeNotes
React Native (Expo or bare)MaestroDetoxMaestro needs no app code changes; Detox gives gray-box sync with the JS thread
FlutterMaestro (reads semantics)integration_test + PatrolAdd Semantics(identifier:)/labels for stable lookup
Native iOS (SwiftUI/UIKit)XCUITestMaestro for cross-platform flowsXCUITest ships with Xcode; best system-alert handling
Native Android (Compose/Views)Compose UI test / EspressoMaestro, UI Automator for cross-appInstrumented tests run in-process with idling sync
Same flows on both platforms, small teamMaestro—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, Orchestrator clearPackageData). 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, waitUntil in 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 extendedWaitUntil for slow screens, not waitForAnimationToEnd chains.
  • 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 --help for 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 (*.release configs); 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 through XCUIApplication(bundleIdentifier: "com.apple.springboard").alerts.buttons["Allow"]. addUIInterruptionMonitor only fires on the next interaction and is unreliable as the sole strategy.
  • The app reads -uitesting via ProcessInfo.processInfo.arguments.contains("-uitesting") and swaps in test dependencies at the composition root.
  • Inspect failures in the .xcresult bundle (open in Xcode, or xcrun xcresulttool); attach screenshots with XCTAttachment at 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

OptionUse when
Mock server (WireMock, MockServer, Prism from OpenAPI) on the CI hostPR runs; deterministic error states
Dedicated E2E environment with seeded accounts per runNightly; real integration
In-app fake repositories behind the E2E flagNative 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-runner action 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 logcat dump, 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

SymptomCauseFix
Element not found intermittentlyAnimation or async loadDisable animations; explicit wait on the target
Passes locally, fails in CISlower emulator, cold bootBoot and warm up before tests; increase only the one wait
Fails after another testLeftover login, DB, keychainClear state per test; Orchestrator; delete: true
Random permission dialogNot pre-grantedsimctl privacy, pm grant, Detox permissions
Keyboard covers buttonSoft keyboardcloseSoftKeyboard(), hideKeyboard, or scroll
Different textLocale/timeFix locale/timezone via launch args
Detox hangsApp never idleStop 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.
  • accessibilityIdentifier is 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

  1. Run each new test 10 times in a row locally; 10/10 green.
  2. Run the suite in CI twice on the same commit; identical results.
  3. Break the journey (wrong password, 500 from mock): the test fails at the right step with a screenshot or video showing it.
  4. 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.

esc