by thetestingacademy
Simple mobile UI testing with YAML flows for iOS and Android without complex setup
npx @qaskills/cli add maestro-mobileAuto-detects your AI agent and installs the skill. Works with Claude Code, Cursor, Copilot, and more.
This skill makes an AI agent write Maestro flows - declarative YAML files that drive iOS and Android apps black-box style with built-in waiting, no test harness compiled into the app, and no WebDriver session management. Trigger it when a repository contains a .maestro/ directory or *.yaml flows with appId: headers, or when the user wants fast, low-maintenance smoke tests for a native, React Native, or Flutter app without the weight of Appium.
tapOn and assertVisible polls until the element appears or a timeout expires. Reach for extendedWaitUntil for slow screens; never add fixed delays as a first resort.checkout-with-saved-card.yaml that does exactly that is debuggable. A 200-line regression.yaml is not. Compose bigger journeys from subflows with runFlow.tapOn: id: "submit-button" survives copy changes and localization; text selectors are acceptable for stable, single-language labels only.launchApp with clearState: true resets the app between flows; tests that inherit the previous flow's logged-in session fail in random orders.env: blocks or -e KEY=value CLI arguments so the same flow runs against dev, staging, and CI builds.# Install the Maestro CLI
curl -fsSL "https://get.maestro.mobile.dev" | bash
export PATH="$PATH:$HOME/.maestro/bin"
maestro --version
# Run one flow against the booted simulator/emulator
maestro test .maestro/login.yaml
# Run the whole suite with JUnit output for CI
maestro test .maestro/ --format junit --output maestro-report.xml
# Interactive selector explorer while authoring
maestro studio
# .maestro/login.yaml
appId: com.example.shop
env:
EMAIL: qa@example.com
PASSWORD: Str0ngPass!
---
- launchApp:
clearState: true
- tapOn: 'Log in'
- tapOn:
id: 'email-input'
- inputText: ${EMAIL}
- tapOn:
id: 'password-input'
- inputText: ${PASSWORD}
- hideKeyboard
- tapOn:
id: 'login-submit'
- assertVisible: 'Welcome back'
- assertNotVisible: 'Invalid email or password'
- takeScreenshot: logged-in-home
# .maestro/subflows/login.yaml -- reusable building block
appId: com.example.shop
---
- launchApp:
clearState: true
- tapOn: 'Log in'
- tapOn:
id: 'email-input'
- inputText: ${EMAIL}
- tapOn:
id: 'password-input'
- inputText: ${PASSWORD}
- tapOn:
id: 'login-submit'
- assertVisible:
id: 'home-screen'
# .maestro/checkout.yaml -- the journey composes the subflow
appId: com.example.shop
env:
EMAIL: qa@example.com
PASSWORD: Str0ngPass!
---
- runFlow: subflows/login.yaml
# Dismiss the push-permission prompt only if it appears
- runFlow:
when:
visible: 'Allow notifications?'
commands:
- tapOn: 'Allow'
- tapOn: 'Deals'
- scrollUntilVisible:
element:
text: 'Wireless Headphones'
direction: DOWN
timeout: 20000
- tapOn: 'Wireless Headphones'
- tapOn:
id: 'add-to-cart'
- tapOn:
id: 'cart-icon'
- assertVisible: 'Wireless Headphones'
- assertVisible:
text: 'Checkout'
enabled: true
# .maestro/order-status.yaml
appId: com.example.shop
---
- launchApp
- tapOn:
id: 'tab-orders'
# Wait up to 15s for async content instead of a blind sleep
- extendedWaitUntil:
visible:
id: 'orders-list'
timeout: 15000
# Pull-to-refresh until the order flips to Shipped, max 5 tries
- repeat:
while:
notVisible: 'Shipped'
times: 5
commands:
- swipe:
direction: DOWN
duration: 400
- assertVisible:
text: 'Shipped'
- copyTextFrom:
id: 'order-number'
- assertTrue: ${maestro.copiedText != ''}
# .github/workflows/maestro-android.yml
name: maestro-android
on: [pull_request]
jobs:
flows:
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
- name: Build debug APK
run: ./gradlew assembleDebug
- name: Install Maestro
run: |
curl -fsSL "https://get.maestro.mobile.dev" | bash
echo "$HOME/.maestro/bin" >> "$GITHUB_PATH"
- name: Run flows on emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
arch: x86_64
profile: pixel_7
script: |
adb install app/build/outputs/apk/debug/app-debug.apk
maestro test .maestro/ --format junit --output maestro-report.xml \
-e EMAIL=ci-bot@example.com -e PASSWORD="${{ secrets.TEST_USER_PASSWORD }}"
- name: Publish report
if: always()
uses: actions/upload-artifact@v4
with:
name: maestro-report
path: |
maestro-report.xml
~/.maestro/tests
For hosted device execution, the same suite uploads unchanged: maestro cloud --api-key "$MAESTRO_CLOUD_API_KEY" app-debug.apk .maestro/ runs every flow on cloud devices and returns a pass/fail exit code CI can gate on.
maestro studio to discover ids and text before writing selectors by hand; it shows exactly what the accessibility tree exposes.testID in React Native, accessibilityIdentifier on iOS, contentDescription/resource-id on Android) as part of feature work.subflows/ and one top-level flow per journey; flows read top to bottom like a manual test script, keep them that readable.tags: [smoke] and run maestro test --include-tags smoke on every PR, the full suite nightly.takeScreenshot at journey milestones; screenshots plus the JUnit report make CI failures diagnosable without a local repro.- waitForAnimationToEnd and long extendedWaitUntil timeouts everywhere to paper over an app that never settles; fix the spinner, not the test.tapOn: point: 50%,30% or index-based taps) for elements that have ids; coordinate taps break on every screen size.env: plus CI secrets..maestro/ directory, YAML files starting with appId:, or maestro commands in CI workflows.- name: Install QA Skills
run: npx @qaskills/cli add maestro-mobile10 of 29 agents supported
Go from zero to Playwright pro: Page Object Model, fixtures, and CI/CD on real projects.
Use code PROMODE at checkout