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
version
1.1.0
category
toolchain
author
tovimx
license
MIT
progressive_disclosure
{"entry_point":{"summary":"Write reliable Maestro mobile E2E tests with declarative YAML flows, testID selectors, auth-aware adaptive patterns, and optimistic update verification","when_to_use":"When writing mobile E2E tests, debugging flaky flows, testing authentication, verifying optimistic updates, capturing screenshots, or setting up Maestro CI for React Native/Expo apps","quick_start":"1. Install Maestro CLI 2. Add testID props to components 3. Write YAML flows with testID selectors 4. Use auth-loaded pre-flight pattern 5. Start mock API server for backend-dependent tests 6. Run with maestro test"},"token_estimate":{"entry":150,"full":9000}}
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
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:
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 && <ViewtestID="auth-loaded"style={{width:0, height:0 }} />}
Then in every test:
-launchApp# Prevent XCTest crash on cold boot (iOS)-swipe:direction:DOWNduration: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:DOWNduration:300
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:truetimeout: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_idtext:"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:
Fetch OTP codes from your email capture service using Maestro's GraalJS runtime:
// CRITICAL: Maestro uses GraalJS — NO async/await, NO fetch()var email = typeofEMAIL !== "undefined" ? EMAIL : "test@example.com";
var emailServiceUrl = typeofEMAIL_SERVICE_URL !== "undefined"
? EMAIL_SERVICE_URL : "http://localhost:8025";
var response = http.get(emailServiceUrl + "/api/v1/messages");
if (!response.ok) {
thrownewError("Failed to fetch emails: " + response.status);
}
var data = json(response.body);
// Find the latest email and extract OTP codevar 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.jsenv: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 templatevar response = http.get("http://localhost:8025/api/endpoint");
if (!response.ok) {
thrownewError("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 clearState to produce guest state on iOS
For auth tests: skip clearState, use auth-loaded pre-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:DOWNduration: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.
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
Use tags to control which tests run in CI vs locally:
# In your flow file headerappId:com.myapptags:-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_KEY secret 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:
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)