maestro-mobile-testing
Maestro Mobile E2E Testing
Overview
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.
Key Features
- Declarative YAML — no imperative test code, just steps
- Automatic waiting — no manual
sleep()or flaky waits - Built-in retry — reduces test flakiness
- Fast execution — runs quickly without setup overhead
- Maestro Studio — interactive test builder (
maestro studio) - Sub-flows — reusable YAML sequences for DRY tests
- JavaScript scripting — GraalJS runtime for HTTP calls and data manipulation
- Maestro Cloud — real device testing in CI without local simulators
Quick Start
Install
curl -Ls "https://get.maestro.mobile.dev" | bash
brew install openjdk@17
export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home
Minimal test
appId: com.myapp
---
- launchApp
- tapOn:
id: "my-button"
- assertVisible: "Expected Text"
Run
maestro test .maestro/smoke-test.yaml
maestro test --debug .maestro/smoke-test.yaml # step through
maestro studio # interactive builder
Core Patterns
1. Selector Strategy: testID vs Text
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 |
# testID selector — stable across translations
- tapOn:
id: "submit-button"
# Text selector — human-readable, self-documenting
- tapOn: "Submit"
When to prefer testIDs:
- App supports multiple languages or will be translated
- UI text is dynamic or frequently changes
- Multiple elements share the same visible text
When to prefer text selectors:
- Single-language app with stable copy
- Readability and self-documentation are a priority
- Testing user-visible behavior exactly as it appears
In React Native, add testID props when using ID-based selectors:
<TouchableOpacity testID="submit-button" onPress={handleSubmit}>
<Text>{t('submit')}</Text>
</TouchableOpacity>
testID Naming Convention
When using ID-based selectors:
{component}-{action/type}[-{variant}]
Examples:
- auth-prompt-login-button
- product-card-{id}
- otp-input-0
- tab-home
- dashboard-loading
2. Auth Pre-Flight Pattern
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:
// In your tab bar or root layout
{!isLoading && <View testID="auth-loaded" style={{ width: 0, height: 0 }} />}
Then in every test:
- 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"
3. Adaptive Tests (Handle Both Auth States)
Tests should work regardless of whether the user is authenticated:
# 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
4. Testing Optimistic Updates
Use short timeouts to verify UI changes happen before server response:
# 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
| Action | Expected Change | Timeout |
|---|---|---|
| Mutation trigger | Button state flips | < 3s |
| List update | Item appears/disappears | < 5s |
| Re-do action | Proves persistence | < 3s |
5. Dismissing Native Alerts
React Native Alert.alert() creates native dialogs that block the UI:
- 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
6. Sub-Flows for Reusability
Break repeated sequences into sub-flow files:
.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
7. Deep Links (Expo)
Use the Expo scheme from app.json, not the bundle ID:
# WRONG
- openLink: "com.myapp://profile/settings"
# CORRECT
- openLink: "myapp://profile/settings"
Deep links must be registered in your app's deep link handler. Unregistered routes silently fail.
8. Platform-Specific Logic
- runFlow:
when:
platform: ios
file: flows/ios-specific.yaml
- runFlow:
when:
platform: android
file: flows/android-specific.yaml
9. Environment Variables
appId: com.myapp
env:
TEST_EMAIL: maestro-test@example.com
API_BASE_URL: http://localhost:3000
---
- inputText: ${TEST_EMAIL}
10. Selector State Properties
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.
# 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
| 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 |
11. Relative Position Selectors
Distinguish between similar elements by their spatial relationship to other elements. This is more idiomatic and resilient than index-based selection.
# 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"
Available relative selectors:
# 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"
| 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 |
Authentication Testing
Architecture
Testing OTP or magic-link authentication in E2E requires capturing emails programmatically. The general pattern:
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Maestro │────▶│ Auth │────▶│ Email Capture │
│ Test │ │ Provider │ │ Service │
└─────────────┘ └──────────────┘ └─────────────────┘
│ │
│ ┌──────────────────────────────┘
│ ▼
│ ┌─────────────┐
└──▶│ REST API │ ─── GET /api/v1/messages
│ (email) │ ─── Extract OTP code
└─────────────┘
Common email capture services: Mailpit, MailHog, Ethereal.
OTP Fetch Script
Fetch OTP codes from your email capture service using Maestro's GraalJS runtime:
// 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];
OTP Input Strategy
OTP components with auto-focus need individual digit entry. Tap each input before typing:
# 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
For provider-specific implementations (Supabase + Mailpit, Firebase Auth, Auth0), create a project-level skill that extends this one.
GraalJS Script Rules
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) |
// 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;
Critical Gotchas
clearState Does NOT Clear iOS Keychain
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.
# 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
Rules:
- Never rely on
clearStateto produce guest state on iOS - For auth tests: skip
clearState, useauth-loadedpre-flight - For guest tests: use adaptive flows that handle both states
- Never assert guest-only UI after
clearState
Note: On Android, clearState: true fully resets app data including credentials. This is an iOS-only gotcha.
XCTest kAXErrorInvalidUIElement Crash (iOS)
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:
- launchApp
- swipe:
direction: DOWN
duration: 100
API Server Dependency
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:
# 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
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.
Auth-Aware Tab Bars
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.
Test File Template
# {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
Folder Structure
.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
Naming Conventions
| 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 |
Infrastructure
Mock Server for API-Dependent Tests
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.
# Start mock server before running Maestro tests
npx tsx scripts/mock-api-server.ts &
Orchestration Script
Automate the full E2E setup with a shell script that:
- Starts backend services (database, auth)
- Seeds test data
- Starts mock API server
- Runs Maestro tests
- Cleans up all services
bash scripts/run-e2e.sh
Seed Data
Tests that depend on specific data require seeded databases. Keep seed scripts alongside your test infrastructure and run them before each test suite.
Android-Specific Patterns
Emulator Setup
Android tests require an emulator or a USB-connected physical device. Maestro auto-detects connected devices.
# 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_test
iOS vs Android Differences
| 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 |
ADB Debugging
adb 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
Android Permission Handling
Android permissions appear as system dialogs. Dismiss with optional taps:
- tapOn:
text: "Allow"
optional: true
- tapOn:
text: "While using the app"
optional: true
CI/CD Integration
GitHub Actions with Maestro Cloud
Maestro Cloud provides real devices in CI without local simulators. Use the official action:
name: 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 }}
Tag-Based Flow Filtering
Use tags to control which tests run in CI vs locally:
# 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/
Local CI with Docker (Android Only)
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/"]
Note: iOS tests cannot run in Docker (requires macOS). Use Maestro Cloud for iOS in CI.
Maestro Cloud
Maestro Cloud runs tests on real devices without local simulator setup.
Setup
- Create account at cloud.maestro.dev
- Generate API key from dashboard
- Store as
MAESTRO_API_KEYsecret in your CI provider
Running from CLI
# 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/
Key Points
- iOS testing: Supported on Maestro Cloud (not on local physical devices)
- Android testing: Both local physical devices and Maestro Cloud
- Results: Dashboard with video recordings, logs, and screenshots
- CI outputs:
MAESTRO_CLOUD_CONSOLE_URL,MAESTRO_CLOUD_FLOW_RESULTS
Maestro MCP Server
The 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.
How It Complements This Skill
| 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.
Setup
The MCP ships with the Maestro CLI — no extra install needed:
# Verify it's available
maestro mcp
Claude Code — add to project .mcp.json or global settings:
{
"mcpServers": {
"maestro": {
"command": "maestro",
"args": ["mcp"]
}
}
}
Claude Desktop — add to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS):
{
"mcpServers": {
"maestro": {
"command": "maestro",
"args": ["mcp"]
}
}
}
Also supported on Cursor, Windsurf, VS Code, and JetBrains IDEs. See the Maestro MCP docs for IDE-specific setup.
Key Capabilities
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 |
Write-Run-Fix Loop
With both this skill and the MCP active, the AI can:
- Write a test YAML using patterns from this skill
- Run it via MCP's
runFlow/launchApp+ interaction tools - See failures via screenshots and assertions
- Fix the YAML and re-run — all in one conversation
Debugging
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
Screenshots saved to ~/.maestro/tests/{timestamp}/.
Checklist for New Tests
[ ] 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)
Common Errors
| 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 |