Maestro Mobile UI Testing: YAML Flows, Device Farms, and CI Integration

Maestro Mobile UI Testing: YAML Flows, Device Farms, and CI Integration

Maestro takes a different approach to mobile UI testing than most frameworks. Instead of writing JavaScript or Python test code, you define flows in YAML. Instead of requiring build-time instrumentation, it works with any app — debug builds, release builds, or even apps you don't have the source for. And instead of a complex synchronization engine, it uses a simple retry-based model that turns out to be surprisingly robust in practice.

This guide covers Maestro from installation through CI integration, with real YAML flow examples and device farm setup.

Why YAML-Based Testing Works

The standard objection to YAML-based testing is that YAML is limited — you can't express complex logic, you can't import utility functions, you can't do real programming. This is mostly true, and mostly fine. The things mobile UI tests need to express are:

  1. Navigate to a screen
  2. Interact with elements
  3. Assert that something is visible or has a value
  4. Repeat or branch occasionally

YAML handles all of this. Where it gets awkward is complex test data generation, intricate conditional logic, or deeply parameterized flows. Maestro handles the common 90% well, and you work around the edge cases.

The practical benefit: anyone on your team can read and write Maestro flows. QA engineers, product managers, and even designers can contribute tests without knowing how to write JavaScript.

Installation

Maestro requires Java 11+ on your machine. Install it with:

# macOS / Linux
curl -Ls <span class="hljs-string">"https://get.maestro.mobile.dev" <span class="hljs-pipe">| bash

<span class="hljs-comment"># Or with Homebrew
brew tap mobile-dev-inc/tap
brew install maestro

Verify:

maestro --version

No additional configuration is needed. Maestro connects to whatever device or simulator is currently attached.

Your First Flow

Create a file flows/login.yaml:

appId: com.yourapp.android  # or bundle ID for iOS
---
- launchApp
- tapOn: "Email"
- inputText: "user@example.com"
- tapOn: "Password"
- inputText: "secret123"
- tapOn: "Sign In"
- assertVisible: "Welcome back"

Run it:

maestro test flows/login.yaml

Maestro will connect to the first available device or simulator and execute the flow. If it can't find "Email", it retries for a configurable timeout (default 20 seconds). This retry model is what replaces Detox's synchronization engine — instead of knowing when the app is idle, Maestro just keeps trying until it succeeds or times out.

Selectors

Maestro finds elements by text, by accessibility identifier, or by element ID:

# By visible text (most common)
- tapOn: "Submit"

# By text using the full selector syntax
- tapOn:
    text: "Submit"

# By accessibility ID (testID in React Native)
- tapOn:
    id: "submit-button"

# By index when multiple elements match
- tapOn:
    text: "Submit"
    index: 1

# By element type
- tapOn:
    type: "Button"

# Combining conditions
- tapOn:
    id: "list-item"
    index: 0

# Within a parent element
- tapOn:
    text: "Delete"
    childOf:
      id: "item-123"

The text matcher does a case-insensitive substring match by default. If you need exact matching:

- tapOn:
    text: "Submit"
    exactText: true

Core Actions

# App lifecycle
- launchApp
- stopApp
- clearState         # Clears app data (like uninstall+reinstall)
- openLink: "myapp://product/123"   # Deep link

# Tap and gestures
- tapOn: "Button Text"
- longPressOn: "Card"
- doubleTapOn: "Image"
- swipe:
    direction: LEFT
    duration: 600

# Scroll
- scroll
- scrollUntilVisible:
    element:
      text: "Item 50"
    direction: DOWN
    timeout: 30000

# Text input
- tapOn: "Search"
- inputText: "react native testing"
- clearText
- hideKeyboard

# Wait
- waitForAnimationToEnd
- wait: 1000   # milliseconds (use sparingly)

# Take screenshot (useful for debugging)
- takeScreenshot: login-screen

Assertions

# Element visibility
- assertVisible: "Success message"
- assertNotVisible: "Error message"

# Full assertion syntax
- assertVisible:
    text: "Items in cart: 3"
    
# Check enabled state
- assertVisible:
    id: "submit-button"
    enabled: true

# Assert element contains text
- assertVisible:
    id: "error-banner"
    text: "Invalid email"

For asserting values in input fields:

- assertVisible:
    id: "email-input"
    text: "user@example.com"

Flow Composition

Large test suites are organized with flow references. You can run one flow from another:

# flows/helpers/authenticate.yaml
appId: com.yourapp
---
- launchApp
- tapOn: "Email"
- inputText: ${EMAIL}
- tapOn: "Password"
- inputText: ${PASSWORD}
- tapOn: "Sign In"
- assertVisible: "Dashboard"
# flows/purchase.yaml
appId: com.yourapp
---
- runFlow:
    file: helpers/authenticate.yaml
    env:
      EMAIL: "buyer@example.com"
      PASSWORD: "buyerpass123"
      
- tapOn: "Browse Products"
- scrollUntilVisible:
    element:
      id: "product-wireless-headphones"
    direction: DOWN
- tapOn:
    id: "product-wireless-headphones"
- tapOn: "Add to Cart"
- assertVisible: "Cart (1)"
- tapOn: "Checkout"
- assertVisible: "Payment Details"

Environment Variables and Parameterization

Use ${VAR_NAME} syntax throughout your flows:

appId: com.yourapp
env:
  BASE_URL: https://api.example.com
  TEST_USER: test@example.com
---
- launchApp:
    arguments:
      apiBaseUrl: ${BASE_URL}
- tapOn: "Email"
- inputText: ${TEST_USER}

Pass env vars at runtime:

maestro test flows/login.yaml -e TEST_USER=other@example.com

This is how you run the same flow against different environments (staging vs production) without duplicating YAML files.

JavaScript for Complex Logic

When you need real programming, Maestro supports inline JavaScript:

- evalScript: |
    const timestamp = Date.now();
    output.generatedEmail = `test+${timestamp}@example.com`;
    
- tapOn: "Email"
- inputText: ${output.generatedEmail}

And for conditional logic:

- runScript:
    file: scripts/setup.js
    env:
      API_KEY: ${API_KEY}
// scripts/setup.js
const response = await fetch(`${process.env.API_URL}/test-user`, {
  method: 'POST',
  headers: { 'Authorization': process.env.API_KEY },
});
const user = await response.json();
output.userId = user.id;
output.email = user.email;

This keeps the YAML readable while moving complexity to proper code.

Organizing a Test Suite

A typical Maestro project structure:

flows/
  helpers/
    authenticate.yaml
    clear-cart.yaml
    create-test-user.yaml
  auth/
    login-happy-path.yaml
    login-wrong-password.yaml
    signup-flow.yaml
    password-reset.yaml
  checkout/
    add-to-cart.yaml
    complete-purchase.yaml
    payment-failure.yaml
  profile/
    edit-profile.yaml
    change-password.yaml

Run the entire suite:

maestro test flows/

Run a subdirectory:

maestro test flows/checkout/

Maestro Cloud — Device Farm Integration

Maestro Cloud runs your flows on real physical devices in their cloud. Upload your app and run tests against actual hardware with a single command:

# Upload app and run flows
maestro cloud --apiKey <span class="hljs-variable">$MAESTRO_CLOUD_API_KEY flows/

<span class="hljs-comment"># Specify app binary
maestro cloud \
  --apiKey <span class="hljs-variable">$MAESTRO_CLOUD_API_KEY \
  --app app/build/app-debug.apk \
  flows/

<span class="hljs-comment"># iOS
maestro cloud \
  --apiKey <span class="hljs-variable">$MAESTRO_CLOUD_API_KEY \
  --app ios/build/YourApp.app \
  flows/

Results come back with screenshots, video recordings, and logs for each flow.

For more granular control:

maestro cloud \
  --apiKey $MAESTRO_CLOUD_API_KEY \
  --app app-debug.apk \
  --include-tags smoke \
  --exclude-tags slow \
  --flows flows/ \
  --async   <span class="hljs-comment"># Don't wait, return immediately with a run ID

Tagging Flows

Tag flows to control which ones run in which contexts:

# flows/purchase.yaml
tags:
  - smoke
  - checkout
  - slow
appId: com.yourapp
---
- runFlow: helpers/authenticate.yaml
# ...
# Run only smoke tests
maestro <span class="hljs-built_in">test --include-tags smoke flows/

<span class="hljs-comment"># Exclude slow tests
maestro <span class="hljs-built_in">test --exclude-tags slow flows/

GitHub Actions CI Integration

# .github/workflows/e2e.yaml
name: E2E Tests

on:
  pull_request:
  push:
    branches: [main]

jobs:
  android-e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'

      - name: Install Maestro
        run: curl -Ls "https://get.maestro.mobile.dev" | bash

      - name: Add Maestro to PATH
        run: echo "$HOME/.maestro/bin" >> $GITHUB_PATH

      - name: Build Android APK
        run: cd android && ./gradlew assembleDebug

      - name: Run E2E on Maestro Cloud
        env:
          MAESTRO_CLOUD_API_KEY: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
        run: |
          maestro cloud \
            --apiKey $MAESTRO_CLOUD_API_KEY \
            --app android/app/build/outputs/apk/debug/app-debug.apk \
            flows/

If you want to run on emulators instead of Maestro Cloud (cheaper for small teams, more complex setup):

  android-emulator-e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm

      - name: AVD Cache
        uses: actions/cache@v4
        id: avd-cache
        with:
          path: |
            ~/.android/avd/*
            ~/.android/adb*
          key: avd-33

      - name: Create AVD
        if: steps.avd-cache.outputs.cache-hit != 'true'
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 33
          force-avd-creation: false
          emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
          disable-animations: true
          script: echo "Generated AVD snapshot"

      - name: Install Maestro
        run: |
          curl -Ls "https://get.maestro.mobile.dev" | bash
          echo "$HOME/.maestro/bin" >> $GITHUB_PATH

      - name: Build and Test
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 33
          emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim
          disable-animations: true
          script: |
            cd android && ./gradlew assembleDebug && cd ..
            adb install android/app/build/outputs/apk/debug/app-debug.apk
            maestro test flows/

iOS in CI

iOS E2E tests in CI require a macOS runner, which costs more on GitHub Actions. Use runs-on: macos-latest:

  ios-e2e:
    runs-on: macos-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Maestro
        run: brew install maestro

      - name: Build iOS app
        run: |
          xcodebuild \
            -workspace ios/YourApp.xcworkspace \
            -scheme YourApp \
            -configuration Debug \
            -sdk iphonesimulator \
            -derivedDataPath ios/build

      - name: Start simulator
        run: |
          xcrun simctl boot "iPhone 15" || true
          xcrun simctl install booted ios/build/Build/Products/Debug-iphonesimulator/YourApp.app

      - name: Run E2E
        run: maestro test flows/

Debugging Flows

Maestro has a Studio — a web-based inspector that shows your device screen alongside the element hierarchy:

maestro studio

This opens a browser window with a live view of your connected device and lets you click on elements to generate the YAML selector for them. It's the fastest way to figure out why a selector isn't working.

For command-line debugging:

# See what elements are on screen right now
maestro hierarchy

<span class="hljs-comment"># Verbose output during test run
maestro <span class="hljs-built_in">test --debug-output ./debug-output flows/login.yaml

The debug output directory will contain screenshots at each step, a JSON dump of the element hierarchy, and logs.

Handling Flakiness

Maestro's retry model handles most flakiness automatically, but you can tune it:

# Increase timeout for a specific assertion
- assertVisible:
    text: "Processing complete"
    timeout: 30000   # 30 seconds

# Optional assertions (don't fail if not visible)
- optional: true
  assertVisible: "Promotional Banner"

# Retry a tap if the element isn't immediately tappable
- retryTapIfNeeded: true
  tapOn: "Submit"

Combining Maestro with Production Monitoring

Maestro solves the development-time problem: catching UI regressions before they ship. But production is different — real users on real devices hit flows you didn't anticipate, APIs return different data than your test fixtures, and third-party integrations introduce their own flakiness.

HelpMeTest runs continuous monitoring against your live app, executing real user flows on real devices on a schedule. It catches the class of issues that development-time testing misses: production API behavior, real device rendering differences, and time-dependent bugs. If you're running Maestro in CI and want the same coverage in production, it's a natural complement.

Conclusion

Maestro's YAML-first approach lowers the barrier to writing mobile E2E tests significantly. The key patterns to internalize:

  1. Write helpers for authentication and common setup — reuse them with runFlow
  2. Use environment variables to parameterize flows across environments
  3. Tag flows and use --include-tags in CI to run fast smoke tests on PRs
  4. Use Maestro Cloud for testing on real devices without managing device infrastructure
  5. Use maestro studio to debug selector issues quickly

The retry-based model means you don't need to deeply understand your app's async behavior to write reliable tests — that's a significant productivity advantage over instrumented frameworks like Detox, especially for teams without dedicated mobile test engineering experience.

Read more