npx skills add ...
npx skills add tovimx/maestro-mobile-testing-skill --skill maestro-mobile-testing
Maestro mobile E2E testing patterns for React Native/Expo apps: YAML test flows, testID selectors, adaptive auth state, optimistic update verification, GraalJS scripting, cross-platform stability, CI/CD integration, Maestro Cloud, and MCP server integration
npx skills add tovimx/maestro-mobile-testing-skill --skill maestro-mobile-testing
Maestro is a declarative YAML-based mobile E2E testing framework. It provides automatic waiting, built-in retry logic, and fast execution without boilerplate. It's more stable than Detox or Appium for React Native apps.
sleep() or flaky waitsmaestro studio)Choose your selector approach based on project context. Both are valid — the right choice depends on whether your app is localized and your team's testing philosophy.
| Context | Recommended Selector | Rationale |
|---|---|---|
| Multi-language / i18n | id: (testID) | Stable across translations |
| Single language | Text labels | Human-readable, self-documenting tests |
| Agent-maintained tests | Either — ask the developer | Readability matters less for AI-maintained flows |
| System dialogs | Text (always) | No testID possible on native alerts |
When to prefer testIDs:
When to prefer text selectors:
In React Native, add testID props when using ID-based selectors:
When using ID-based selectors:
Prevent race conditions where Maestro interacts with the UI before auth state resolves. Add a zero-size auth-loaded marker that only renders when auth loading completes:
Then in every test:
Tests should work regardless of whether the user is authenticated:
Use short timeouts to verify UI changes happen before server response:
| Action | Expected Change | Timeout |
|---|---|---|
| Mutation trigger | Button state flips | < 3s |
| List update | Item appears/disappears | < 5s |
| Re-do action | Proves persistence | < 3s |
React Native Alert.alert() creates native dialogs that block the UI:
Break repeated sequences into sub-flow files:
Use the Expo scheme from app.json, not the bundle ID:
Deep links must be registered in your app's deep link handler. Unregistered routes silently fail.
Use enabled, selected, checked, and focused to target elements by their current state. This is useful for validating interactive element states before or after actions.
| Property | Values | Use Case |
|---|---|---|
enabled | true / false | Buttons that disable during submission or until form is valid |
checked | true / false | Checkboxes, toggle switches |
selected | true / false | Tab items, segmented controls |
focused | true / false | Input fields with auto-focus |
Distinguish between similar elements by their spatial relationship to other elements. This is more idiomatic and resilient than index-based selection.
Available relative selectors:
| Selector | Meaning |
|---|---|
below: | Element is positioned below the referenced element |
above: | Element is positioned above the referenced element |
leftOf: | Element is to the left of the referenced element |
rightOf: | Element is to the right of the referenced element |
childOf: | Element is a direct child of the referenced parent |
containsChild: | Element contains a direct child matching the reference |
containsDescendants: | Element contains all specified descendant elements |
Testing OTP or magic-link authentication in E2E requires capturing emails programmatically. The general pattern:
Common email capture services: Mailpit, MailHog, Ethereal.
Fetch OTP codes from your email capture service using Maestro's GraalJS runtime:
OTP components with auto-focus need individual digit entry. Tap each input before typing:
For provider-specific implementations (Supabase + Mailpit, Firebase Auth, Auth0), create a project-level skill that extends this one.
Maestro uses the GraalJS runtime. These constraints are non-negotiable:
| Feature | Status |
|---|---|
async/await | NOT supported |
fetch() | NOT supported |
http.get(), http.post() | Use these instead |
json() | Use to parse response bodies |
output.VAR | Set variables for use in YAML flow |
var declarations | Required (use var, not const/let for safety) |
clearState: true clears the app sandbox (UserDefaults, files, caches) but does NOT clear the iOS Keychain. Auth tokens stored via expo-secure-store (or any Keychain-based storage) persist across clearState resets and even app reinstalls.
Rules:
clearState to produce guest state on iOSclearState, use auth-loaded pre-flightclearStateNote: On Android, clearState: true fully resets app data including credentials. This is an iOS-only gotcha.
The XCTest driver may crash if Maestro interacts with the accessibility tree before the first render cycle completes on cold boot.
Fix: Add a no-op swipe immediately after launchApp:
Mobile apps calling backend APIs on localhost need either the full server or a mock server running. Without it, all API-dependent screens show loading spinners or empty states (queries fail silently).
Fix: Start a mock API server before running Maestro tests:
Create a lightweight mock that returns canned JSON for each endpoint your app calls. This is faster and more deterministic than running your full backend.
Tab bars that show different tabs for guest vs authenticated users will cause selector failures:
| State | Typical Tabs |
|---|---|
| Guest | home, search, cart, profile |
| Auth | home, feed, create, messages, profile |
Only assert tabs that exist in both states, or use adaptive when: conditions.
| Type | Pattern | Example |
|---|---|---|
| Main test | {feature}-{action}.yaml | checkout-purchase.yaml |
| Sub-flow | {action}-{context}.yaml | auth-and-return-to-dashboard.yaml |
| Script | {verb}-{noun}.js | fetch-otp.js |
Create a lightweight mock server that serves canned responses for your API layer. This is faster and more deterministic than running your full backend during E2E tests.
Automate the full E2E setup with a shell script that:
Tests that depend on specific data require seeded databases. Keep seed scripts alongside your test infrastructure and run them before each test suite.
Android tests require an emulator or a USB-connected physical device. Maestro auto-detects connected devices.
| Aspect | iOS | Android |
|---|---|---|
| Device type | Simulator only (no physical) | Emulator + physical via ADB |
clearState | Does NOT clear Keychain | Fully resets app data |
| Cold boot crash | XCTest kAXError (add swipe delay) | No equivalent issue |
| Performance | Runs natively (fast) | ARM emulation (slower on x86) |
| Permission dialogs | System alerts | System dialogs with different text |
Android permissions appear as system dialogs. Dismiss with optional taps:
Maestro Cloud provides real devices in CI without local simulators. Use the official action:
Use tags to control which tests run in CI vs locally:
Note: iOS tests cannot run in Docker (requires macOS). Use Maestro Cloud for iOS in CI.
Maestro Cloud runs tests on real devices without local simulator setup.
MAESTRO_API_KEY secret in your CI providerMAESTRO_CLOUD_CONSOLE_URL, MAESTRO_CLOUD_FLOW_RESULTSThe Maestro MCP server exposes Maestro's full command set as Model Context Protocol tools, letting AI agents execute tests and interact with devices directly — not just write YAML.
| This Skill | Maestro MCP | |
|---|---|---|
| Role | Teaches correct patterns | Provides runtime execution |
| Layer | Authoring (write good YAML) | Execution (run, tap, assert, screenshot) |
| Output | Better test files | Live device interaction |
Use both together: this skill ensures the AI writes correct tests; the MCP lets it run them immediately, see failures, and iterate.
The MCP ships with the Maestro CLI — no extra install needed:
Claude Code — add to project .mcp.json or global settings:
Claude Desktop — add to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):
Also supported on Cursor, Windsurf, VS Code, and JetBrains IDEs. See the Maestro MCP docs for IDE-specific setup.
The MCP server exposes 47 tools organized by category:
| Category | Tools | Examples |
|---|---|---|
| UI Interaction | tap, swipe, scroll, long press | tapOn, scrollUntilVisible |
| Text Input | type, erase, paste, copy | inputText, eraseText |
| Assertions | visibility, AI-powered | assertVisible, assertWithAI |
| App Lifecycle | launch, stop, clear state | launchApp, clearState |
| Device Control | location, orientation, airplane | setLocation, hideKeyboard |
| Flow Control | run flows, repeat, eval scripts | runFlow, evalScript |
| Media | screenshots, recording | takeScreenshot, startRecording |
| AI-Powered | visual assertions, defect detection | assertWithAI, assertNoDefectsWithAI |
With both this skill and the MCP active, the AI can:
runFlow / launchApp + interaction toolsScreenshots saved to ~/.maestro/tests/{timestamp}/.
| Error | Cause | Fix |
|---|---|---|
| "Unable to locate Java Runtime" | Java not in PATH | export JAVA_HOME=/opt/homebrew/opt/openjdk@17/... |
| "Element not found" after tap | Native alert blocking | Add tapOn: text: "OK" optional: true |
| OTP digits not entering | Auto-focus interference | Use individual otp-input-N testIDs |
| Test passes but nothing happened | optional: true misused | Only use optional for truly optional actions |
| "Assertion is false" on visibility | Element not rendered yet | Increase timeout or verify testID exists |
| Script output empty | Wrong JS API | Use http.get() not fetch() |
| Auth state inconsistent after clearState | iOS Keychain not cleared | Don't use clearState, use adaptive flows |
| kAXErrorInvalidUIElement crash | Cold boot race (iOS) | Add post-launch swipe delay |
| Loading spinners / empty screens | No API server running | Start mock API server before tests |
| Permission dialog blocking (Android) | System dialog not dismissed | Add tapOn: text: "Allow" optional: true |
maestro test .maestro/smoke-test.yaml
maestro test --debug .maestro/smoke-test.yaml # step through
maestro studio # interactive builder# testID selector — stable across translations
- tapOn:
id: "submit-button"
# Text selector — human-readable, self-documenting
- tapOn: "Submit"<TouchableOpacity testID="submit-button" onPress={handleSubmit}>
<Text>{t('submit')}</Text>
</TouchableOpacity>{component}-{action/type}[-{variant}]
Examples:
- auth-prompt-login-button
- product-card-{id}
- otp-input-0
- tab-home
- dashboard-loading// In your tab bar or root layout
{!isLoading && <View testID="auth-loaded" style={{ width: 0, height: 0 }} />}- launchApp
# Prevent XCTest crash on cold boot (iOS)
- swipe:
direction: DOWN
duration: 100
# Wait for auth state to resolve
- extendedWaitUntil:
visible:
id: "auth-loaded"
timeout: 15000
# Now safe to interact
- tapOn:
id: "tab-home"# Auth flow — only runs if login prompt is visible
- runFlow:
when:
visible: "Sign In"
file: flows/auth-flow.yaml
# Already authenticated — proceed directly
- runFlow:
when:
visible:
id: "tab-home"
file: flows/authenticated-action.yaml# Trigger mutation
- tapOn:
id: "action-button"
# OPTIMISTIC: UI must change within 3s (not waiting for server)
- extendedWaitUntil:
visible:
id: "undo-button"
timeout: 3000
# Verify derived UI state
- extendedWaitUntil:
visible:
id: "user-indicator"
timeout: 5000- tapOn:
id: "action-button"
# Wait for expected state change first
- extendedWaitUntil:
visible:
id: "new-state-element"
timeout: 5000
# Dismiss alert (optional in case it already closed)
- tapOn:
text: "OK"
optional: true
# Brief delay for alert animation
- swipe:
direction: DOWN
duration: 300.maestro/
├── flows/
│ ├── auth-and-return.yaml
│ ├── complete-purchase.yaml
│ └── verify-result.yaml
├── smoke-test.yaml
└── feature-test.yaml# In main test
- runFlow:
file: flows/auth-and-return.yaml# WRONG
- openLink: "com.myapp://profile/settings"
# CORRECT
- openLink: "myapp://profile/settings"- runFlow:
when:
platform: ios
file: flows/ios-specific.yaml
- runFlow:
when:
platform: android
file: flows/android-specific.yamlappId: com.myapp
env:
TEST_EMAIL: maestro-test@example.com
API_BASE_URL: http://localhost:3000
---
- inputText: ${TEST_EMAIL}# Only tap the submit button if it's enabled
- tapOn:
id: "submit-button"
enabled: true
# Assert a checkbox is checked
- assertVisible:
id: "terms-checkbox"
checked: true
# Wait for an input to be focused
- extendedWaitUntil:
visible:
id: "email-input"
focused: true
timeout: 3000# BAD — fragile, breaks if order changes
- tapOn:
text: "Add to Basket"
index: 1
# GOOD — contextual, self-documenting
- tapOn:
text: "Add to Basket"
below:
text: "Awesome Shoes"# Target element below another
- tapOn:
text: "Buy Now"
below: "Product Title"
# Target element that is a child of a parent
- tapOn:
text: "Delete"
childOf:
id: "item-card-42"
# Target a parent that contains a specific child
- tapOn:
containsChild: "Urgent"
# Target by multiple descendants
- tapOn:
containsDescendants:
- id: title_id
text: "Specific Title"
- "Another descendant text"
# Horizontal positioning
- tapOn:
text: "Edit"
rightOf: "Username"┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Maestro │────▶│ Auth │────▶│ Email Capture │
│ Test │ │ Provider │ │ Service │
└─────────────┘ └──────────────┘ └─────────────────┘
│ │
│ ┌──────────────────────────────┘
│ ▼
│ ┌─────────────┐
└──▶│ REST API │ ─── GET /api/v1/messages
│ (email) │ ─── Extract OTP code
└─────────────┘// CRITICAL: Maestro uses GraalJS — NO async/await, NO fetch()
var email = typeof EMAIL !== "undefined" ? EMAIL : "test@example.com";
var emailServiceUrl = typeof EMAIL_SERVICE_URL !== "undefined"
? EMAIL_SERVICE_URL : "http://localhost:8025";
var response = http.get(emailServiceUrl + "/api/v1/messages");
if (!response.ok) {
throw new Error("Failed to fetch emails: " + response.status);
}
var data = json(response.body);
// Find the latest email and extract OTP code
var body = data.messages[0].Content.Body;
var match = body.match(/(\d{6})/);
output.OTP_CODE = match[1];# Split OTP into digits via helper script
- runScript:
file: scripts/split-otp.js
env:
OTP_CODE: ${output.OTP_CODE}
# Enter each digit by tapping its input
- tapOn:
id: "otp-input-0"
- inputText: ${output.OTP_0}
- tapOn:
id: "otp-input-1"
- inputText: ${output.OTP_1}
# ... repeat for all digits// Script template
var response = http.get("http://localhost:8025/api/endpoint");
if (!response.ok) {
throw new Error("Request failed: " + response.status);
}
var data = json(response.body);
output.RESULT = data.value;# WRONG — user may still be authenticated
- launchApp:
clearState: true
- assertVisible: "Welcome" # Fails if Keychain has tokens
# CORRECT — wait for auth resolution, then adapt
- launchApp
- extendedWaitUntil:
visible:
id: "auth-loaded"
timeout: 15000- launchApp
- swipe:
direction: DOWN
duration: 100# Start mock server (serves canned responses on your API port)
npx tsx scripts/mock-api-server.ts &
# Then run tests
maestro test .maestro/my-test.yaml# {Feature} {Action} Test
#
# Tests: {what this validates}
# Prerequisites:
# - Simulator/emulator running with app installed
# - Backend or mock server running (if API-dependent)
appId: com.myapp
env:
TEST_EMAIL: maestro-{feature}@example.com
EMAIL_SERVICE_URL: http://localhost:8025
---
# ==========================================
# STEP 1: LAUNCH + AUTH PRE-FLIGHT
# ==========================================
- launchApp
- swipe:
direction: DOWN
duration: 100
- extendedWaitUntil:
visible:
id: "auth-loaded"
timeout: 15000
- takeScreenshot: 01-initial-state
# ==========================================
# STEP 2: {ACTION}
# ==========================================
- tapOn:
id: "target-element"
# ==========================================
# STEP 3: VERIFY
# ==========================================
- extendedWaitUntil:
visible:
id: "expected-result"
timeout: 5000
- takeScreenshot: 02-final-state.maestro/
├── README.md # Quick reference + testID inventory
├── config.yaml # Shared configuration
├── flows/ # Reusable sub-flows
│ ├── auth-and-return.yaml
│ ├── complete-action.yaml
│ └── verify-result.yaml
├── scripts/ # GraalJS helpers
│ ├── fetch-otp.js
│ └── split-otp.js
├── smoke-test.yaml # Guest navigation
├── auth-signin.yaml # OTP sign-in flow
├── feature-screenshots.yaml # Screenshot capture flows
└── feature-action.yaml # Feature-specific tests
scripts/
├── mock-api-server.ts # Lightweight mock for E2E
└── run-e2e.sh # Orchestration script# Start mock server before running Maestro tests
npx tsx scripts/mock-api-server.ts &bash scripts/run-e2e.sh# List available system images
sdkmanager --list | grep system-images
# Create emulator
avdmanager create avd -n maestro_test \
-k "system-images;android-34;google_apis;arm64-v8a"
# Start emulator
emulator -avd maestro_testadb devices # List connected devices
adb shell am start -n com.myapp/.MainActivity # Launch app
adb logcat | grep Maestro # Filter Maestro logs
adb shell input keyevent 82 # Unlock screen- tapOn:
text: "Allow"
optional: true
- tapOn:
text: "While using the app"
optional: truename: Mobile E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
maestro-e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Build Android APK
- uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Build Android APK
run: |
cd apps/mobile
npx expo prebuild --platform android --no-install
cd android && ./gradlew assembleRelease
- name: Run Maestro Cloud Tests
id: maestro
uses: mobile-dev-inc/action-maestro-cloud@v2
with:
api-key: ${{ secrets.MAESTRO_API_KEY }}
app-file: apps/mobile/android/app/build/outputs/apk/release/app-release.apk
workspace: .maestro
include-tags: ci
# Access results
# ${{ steps.maestro.outputs.MAESTRO_CLOUD_CONSOLE_URL }}
# ${{ steps.maestro.outputs.MAESTRO_CLOUD_UPLOAD_STATUS }}
# ${{ steps.maestro.outputs.MAESTRO_CLOUD_FLOW_RESULTS }}# In your flow file header
appId: com.myapp
tags:
- ci
- smoke
---
- launchApp
# ... test steps# Run only CI-tagged flows locally
maestro test --include-tags ci .maestro/
# Exclude work-in-progress flows
maestro test --exclude-tags wip .maestro/FROM openjdk:17-slim
RUN curl -Ls "https://get.maestro.mobile.dev" | bash
ENV PATH="/root/.maestro/bin:${PATH}"
COPY .maestro/ /app/.maestro/
WORKDIR /app
CMD ["maestro", "test", ".maestro/"]# Upload and run on Maestro Cloud
maestro cloud --api-key $MAESTRO_API_KEY \
--app-file ./app-release.apk \
.maestro/
# With tag filtering
maestro cloud --api-key $MAESTRO_API_KEY \
--app-file ./app-release.apk \
--include-tags smoke \
.maestro/# Verify it's available
maestro mcp{
"mcpServers": {
"maestro": {
"command": "maestro",
"args": ["mcp"]
}
}
}{
"mcpServers": {
"maestro": {
"command": "maestro",
"args": ["mcp"]
}
}
}maestro test --debug .maestro/test.yaml # Step through interactively
maestro record .maestro/test.yaml # Record as video
maestro studio # Interactive UI builder
maestro hierarchy # View element tree[ ] Unique test email (maestro-{feature}@example.com)
[ ] Selector strategy chosen (testID for i18n apps, text for single-language — see Pattern 1)
[ ] Selectors use state properties where relevant (enabled, checked — see Pattern 10)
[ ] Similar elements distinguished with relative selectors, not index (see Pattern 11)
[ ] Auth pre-flight pattern used (auth-loaded)
[ ] Post-launch swipe added (iOS crash prevention)
[ ] Both auth states handled (adaptive flows)
[ ] Native alerts dismissed after mutations
[ ] Short timeouts for optimistic updates (3-5s)
[ ] Sub-flows created for reusable sequences
[ ] Descriptive screenshots at key points
[ ] Header comment with prerequisites
[ ] Added to README.md test table
[ ] Mock API server started if backend-dependent
[ ] Tags added for CI filtering (ci, smoke, wip)