Maestro Mobile Testing: Declarative YAML Flows for iOS and Android
Maestro is a mobile testing framework that replaces imperative test code with declarative YAML flows. You describe what the user does — tap this, type that, assert visible — and Maestro handles synchronization, retries, and cross-platform execution. This guide covers the flow syntax, handling dynamic content, CI integration, and how Maestro compares to Detox for different testing scenarios.
Key Takeaways
YAML flows are readable by non-engineers. Product managers and QA analysts can read and write Maestro flows without knowing how to code, which means tests don't become a bottleneck owned by one person. Maestro auto-retries flaky interactions by default. Unlike Detox's synchronization model, Maestro uses a retry-with-timeout strategy — simpler to understand, slightly more tolerant of genuinely slow UI. runFlow enables test composition. Break long user journeys into reusable sub-flows and compose them — authentication flows reused across many tests without duplication. Maestro Studio is the fastest way to build locators. Launch it against a running app and click elements to generate correct YAML selectors rather than guessing testID values. For large cross-platform teams, Maestro Cloud provides parallelism without managing device infrastructure. Local execution is free; Cloud scales horizontally across real devices.
The barrier to writing mobile E2E tests has historically been high. You need to know how to write code, understand async programming models, configure build systems, and navigate testing framework documentation before a single test runs. Maestro lowers this barrier dramatically by replacing code with YAML that reads like a step-by-step user guide.
Installing Maestro
Maestro ships as a single binary installed via a shell script:
curl -Ls "https://get.maestro.mobile.dev" <span class="hljs-pipe">| bashVerify the installation:
maestro --versionNo project-level dependencies, no package.json changes, no Gradle configuration. Maestro connects to your running app over the iOS Simulator or Android Emulator bridge.
The Flow Syntax
A Maestro flow is a YAML file with two required sections: appId and a list of commands.
# flows/login.yaml
appId: com.example.myapp
---
- launchApp
- tapOn: "Email"
- inputText: "user@example.com"
- tapOn: "Password"
- inputText: "secret123"
- tapOn: "Log In"
- assertVisible: "Welcome back!"Run it:
maestro test flows/login.yamlMaestro launches the app, executes each command, and exits with a pass or fail result. That's the entire workflow for a basic test.
Core Commands
Navigation and Interaction
# Launch and navigate
- launchApp
- launchApp:
clearState: true # Fresh app state
# Tap by text or testID
- tapOn: "Sign In"
- tapOn:
id: "submit-button" # Uses testID/accessibilityIdentifier
- tapOn:
text: "Confirm"
index: 1 # Second element matching this text
# Long press
- longPressOn: "Delete item"
# Swipe
- swipe:
direction: LEFT
element:
id: "swipeable-card"
# Scroll
- scroll
- scrollUntilVisible:
element:
text: "Load More"
direction: DOWNText Input
- tapOn: "Search"
- inputText: "React Native testing"
- clearText # Clears focused input
- hideKeyboardAssertions
# Check visibility
- assertVisible: "Dashboard"
- assertVisible:
text: "3 items"
- assertVisible:
id: "profile-avatar"
# Check not visible
- assertNotVisible: "Error message"
# Check element contains text (partial match)
- assertVisible:
text: "Hello," # matches "Hello, Alice"
enabled: trueWaiting
Maestro retries visibility assertions automatically, but you can set explicit waits:
- waitForAnimationToEnd
- wait:
maxRetries: 10
interval: 500 # ms between retries
# Wait for element to appear
- assertVisible:
text: "Processing complete"
timeout: 10000 # msHandling Dynamic Content
Real apps have content that changes — timestamps, user names, counts. Maestro handles this with JavaScript expressions inside YAML:
appId: com.example.myapp
---
- launchApp
- tapOn: "Profile"
- assertVisible:
text: ${USER_NAME} # Injected at runtime via env varRun with environment variables:
USER_NAME="Alice Johnson" maestro <span class="hljs-built_in">test flows/profile.yamlFor more complex conditions, use the evalScript command:
- evalScript: ${new Date().getFullYear() >= 2025}Conditional Flows
Handle optional UI states like permission dialogs:
- runFlow:
when:
visible: "Allow access to camera?"
file: flows/helpers/grant-camera-permission.yamlThe runFlow with when is idempotent — it only runs the sub-flow if the condition is true. This pattern cleanly handles OS-level permission prompts that appear only on first launch.
Composing Flows with runFlow
Long user journeys should be broken into composable sub-flows:
# flows/helpers/authenticate.yaml
appId: com.example.myapp
---
- launchApp:
clearState: true
- tapOn: "Email"
- inputText: ${EMAIL}
- tapOn: "Password"
- inputText: ${PASSWORD}
- tapOn: "Log In"
- assertVisible: "Dashboard"# flows/checkout.yaml
appId: com.example.myapp
---
- runFlow:
file: flows/helpers/authenticate.yaml
env:
EMAIL: "buyer@example.com"
PASSWORD: "testpass123"
- tapOn: "Shop"
- tapOn: "Add to Cart"
- tapOn: "Checkout"
- assertVisible: "Order confirmed"This pattern keeps flows focused and eliminates the authentication boilerplate that would otherwise be duplicated across dozens of test files.
Cross-Platform Testing
The same flow runs on iOS and Android. Maestro abstracts platform differences in element interaction. The only time you need platform-specific flows is when the app renders meaningfully different UI per platform:
# flows/platform-specific.yaml
appId: com.example.myapp
---
- runFlow:
when:
platform: iOS
file: flows/ios/share-sheet.yaml
- runFlow:
when:
platform: Android
file: flows/android/share-sheet.yamlTo target a specific device or simulator, set the device connection before running:
# iOS — target a specific simulator
maestro <span class="hljs-built_in">test flows/login.yaml --device <span class="hljs-string">"iPhone 15"
<span class="hljs-comment"># Android — use a specific emulator or device
maestro <span class="hljs-built_in">test flows/login.yaml --device emulator-5554Maestro Studio
Maestro Studio is a browser-based UI for inspecting your app and building flows interactively:
maestro studioIt opens a browser showing your app's current screen with an element inspector. Click any element to get its text, ID, and the exact YAML command to interact with it. For apps without testID props everywhere, Studio is the fastest way to discover reliable selectors.
Studio also supports replaying individual commands in real time — paste a command, press run, and watch the interaction happen on the device. This shortens the iteration loop from minutes to seconds.
Running Flows in CI
GitHub Actions — Local Execution
# .github/workflows/maestro.yml
name: Maestro E2E Tests
on:
push:
branches: [main]
jobs:
test-android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Maestro
run: curl -Ls "https://get.maestro.mobile.dev" | bash
- name: Build APK
run: cd android && ./gradlew assembleDebug
- name: Run Android Emulator and Tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
arch: x86_64
script: |
adb install android/app/build/outputs/apk/debug/app-debug.apk
$HOME/.maestro/bin/maestro test flows/ --format junit --output results.xml
- name: Upload results
if: always()
uses: actions/upload-artifact@v4
with:
name: maestro-results
path: results.xml
test-ios:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Install Maestro
run: curl -Ls "https://get.maestro.mobile.dev" | bash
- name: Build iOS app
run: xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -sdk iphonesimulator -configuration Debug
- name: Boot simulator
run: |
xcrun simctl boot "iPhone 15"
xcrun simctl install booted path/to/MyApp.app
- name: Run tests
run: $HOME/.maestro/bin/maestro test flows/ --format junit --output results.xmlMaestro Cloud
For parallelism across real devices without managing infrastructure:
maestro cloud --apiKey $MAESTRO_API_KEY flows/Maestro Cloud runs your flows on a pool of real iOS and Android devices simultaneously, returning results in parallel. It integrates with GitHub Actions, Bitrise, CircleCI, and Fastlane.
Debugging Failed Flows
When a flow fails, Maestro captures a screenshot and the app hierarchy at the point of failure:
maestro test flows/login.yaml --debug-output ./debug-output/The debug directory contains:
- A screenshot at the failure point
- The view hierarchy dump (XML on Android, AXI on iOS)
- The flow execution log with each command's result
For interactive debugging, use maestro record to capture a flow by interacting with your device manually, then clean up the recorded YAML:
maestro record flows/new-flow.yamlMaestro vs Detox
Both are solid choices; the right answer depends on your team:
| Dimension | Maestro | Detox |
|---|---|---|
| Language | YAML | TypeScript/JavaScript |
| Synchronization | Retry-based | Event-driven gray-box |
| Setup complexity | Minimal | Significant (native config) |
| Composability | runFlow | Jest modules |
| Debugger | Maestro Studio | None built-in |
| Custom logic | evalScript (limited) | Full JS |
| Real devices (CI) | Maestro Cloud | DIY or third-party |
Maestro wins when speed of writing tests matters more than test logic complexity. Detox wins when tests require conditional logic, data manipulation, or integration with existing JavaScript tooling. Many teams use both: Maestro for happy-path smoke tests, Detox for complex flows.
The YAML-first approach makes Maestro particularly effective for teams where QA or product defines the test scenarios — the format is accessible enough that the person who knows what to test can also be the person who writes the test.