Maestro Testing: A Practical Guide to YAML Mobile Flows
Maestro testing means describing a mobile user journey (open the app, type an email, tap Sign in, check the home screen) in a short YAML file called a Flow, which the open-source Maestro framework then plays against the installed build on an emulator, simulator or phone. Because Maestro reads the screen through the accessibility layer instead of hooking into your source code, one approach covers native Android, native iOS, React Native and Flutter apps without adding a test library to any of them.
The project ships in three pieces, and most teams end up touching all of them:
- Maestro Studio is a desktop app for Windows, macOS and Linux where you inspect a running screen and build a Flow visually.
- Maestro CLI is the open-source engine you call from a terminal or a CI job with commands such as
maestro test.
- Maestro Cloud is the paid hosted service that runs your Flows in parallel on virtual Android devices and iOS Simulators.
This guide follows one path from laptop to pipeline, covering the internals, a first login Flow, the commands worth learning first, platform differences, React Native and Flutter, CI, and the point where a growing Flow library starts asking for real maintenance time.
What is Maestro testing?
Picture a manual tester working through a grocery app checkout: they search for oat milk, add two cartons, open the cart and confirm the total. Maestro testing is the practice of writing exactly that sequence as plain YAML steps, so a machine repeats it on every build while the tester moves on to exploratory work.
The Maestro docs describe the tool as a UI automation framework for mobile and web, released under the Apache 2.0 license. Each line in a Flow is one user action or one check, and nothing in the file refers to Kotlin classes, Swift views or JavaScript components. That is why product owners can usually read a Flow during a pull request review, and why a step like tapOn: "Add to cart" documents the journey while it tests it.
How does Maestro work under the hood?
Maestro keeps your app at arm's length, and the page on how Maestro works explains the mechanics: the CLI installs a small companion driver app on the Android or iOS device, and that driver reads the accessibility tree the operating system already builds for screen readers. Since Maestro pilots the device rather than your app process, it can also tap through permission dialogs, the notification shade and the Settings app when a journey needs them.
Timing is handled for you, because every command waits for the screen to settle before acting, and assertVisible retries for up to 7 seconds before reporting a failure. When a screen genuinely takes longer, such as a payment confirmation waiting on a sandbox gateway, extendedWaitUntil sets a longer timeout for that one step, so a healthy suite has no sleep() calls scattered through it.
This differs from Appium, where a client library sends WebDriver commands to a server that drives UiAutomator2 or XCUITest underneath. If you are weighing that driver stack against lighter options, our roundup of Appium alternatives covers the trade-offs.
How do you install Maestro and run your first flow?
The CLI needs Java 17 or newer, and the troubleshooting notes suggest Java 17 or 21 if you hit odd startup errors. The CLI install guide gives one route per operating system:
- On macOS or Linux, run
curl -fsSL "https://get.maestro.mobile.dev" | bash in a terminal.
- On macOS with Homebrew, run
brew tap mobile-dev-inc/tap and then brew install mobile-dev-inc/tap/maestro.
- On Windows, download
maestro.zip from the GitHub releases page, unzip it and add its bin folder to your PATH.
Run maestro --help afterwards to confirm the binary is on your path, or follow the QuickStart if you would rather install Maestro Studio and pick an app id from a dropdown.
Next, boot a target device by starting an Android emulator from Android Studio's Device Manager or an iPhone simulator from Xcode, and install a debug build of your app on it. Then save this as login.yaml, swapping in your own package name and element ids:
appId: com.example.shop
---
- launchApp: { clearState: true }
- tapOn: { id: "email_input" }
- inputText: "qa@example.com"
- tapOn: { id: "password_input" }
- inputText: "Test1234!"
- tapOn: "Sign in"
- assertVisible: "Welcome back"
Run it with maestro test login.yaml and watch the emulator type and tap on its own. While you edit, maestro test -c login.yaml reruns the Flow every time you save the file. When you are unsure which id or label an element carries, maestro hierarchy prints the current view tree so you can copy the exact value instead of guessing.
What goes into a Maestro flow?
Every Flow has two parts separated by a line of three dashes. Above the dashes sits configuration, meaning the appId plus optional fields such as name, tags for filtering runs, env values, and hooks like onFlowStart and onFlowComplete for setup and cleanup. Below the dashes comes the ordered list of commands, and the Flows overview shows how this layout grows from one file into a full workspace.
The Maestro commands you will use first
| Command |
What it does |
Example |
| launchApp |
Opens the app, optionally wiping stored data and logins first |
- launchApp: { clearState: true } |
| tapOn |
Taps the element matching a label, an id or a screen point |
- tapOn: { id: "login_button" } |
| inputText |
Types characters into whichever field currently has focus |
- inputText: "qa@example.com" |
| assertVisible |
Fails the run if an element never shows, after retrying for up to 7 seconds |
- assertVisible: "Welcome back" |
| scrollUntilVisible |
Scrolls a list such as a product feed until the target row appears |
- scrollUntilVisible: { element: { text: "Checkout" } } |
| runFlow |
Calls a shared subflow file, such as login, from inside another Flow |
- runFlow: subflows/login.yaml |
| extendedWaitUntil |
Waits beyond the default for slow screens like a payment result |
- extendedWaitUntil: { visible: "Order placed", timeout: 15000 } |
| takeScreenshot |
Saves a PNG of the current screen into the run output folder |
- takeScreenshot: cart |
Your choice of selectors decides how stable those commands stay as the app changes. The selectors guide supports matching on visible text, on an id, on position relative to another element (below, childOf) and on state such as enabled. A sensible order is an id first, visible text second, and relational selectors for a cart where three rows show the same "Remove" label and you need the one under "Oat milk".
Once a suite passes ten or so Flows, reuse starts paying off. Put sign-in in subflows/login.yaml and call it with runFlow, so a redesigned sign-in screen means one edit instead of twenty. Conditions let a step run only when an element is visible, when it is absent, or on a given platform. For test data, Maestro runs JavaScript in a restricted sandbox that can call an HTTP endpoint to seed an account but cannot read your local file system.
How does Maestro mobile testing differ on Android and iOS?
On Android, Maestro mobile testing works with emulators and with physical phones connected over USB, so the same Flow can run on a Pixel emulator during development and on a budget Samsung handset before release. Here the appId you write is the Android package name, such as com.example.shop.
On iOS, the supported platforms page lists Xcode Simulators, and Maestro states that physical iPhones are not supported, so every iOS run happens on a simulator. The appId becomes the bundle identifier, such as com.example.Shop, and the build you install must be a simulator build rather than a signed device IPA.
You can still keep a single Flow for both platforms by writing appId: ${APP_ID} in the config and passing the value with maestro test -e APP_ID=com.example.shop login.yaml. Where screens truly diverge, such as an iOS notification prompt Android never shows, wrap the extra steps in a runFlow with a when: platform: iOS condition so the Android run skips them. Many teams pair this with Espresso testing or XCUITest for fast in-process checks, keeping Maestro for journeys that cross several screens.
Does Maestro work with React Native and Flutter apps?
For React Native, the React Native page confirms support on Android and iOS with no npm package added to the app. A component's testID surfaces as the id Maestro matches, so <Pressable testID="checkout_button"> becomes tapOn: { id: "checkout_button" }. The docs warn that text selectors break when a label changes or the app is translated, and that apps opened through Expo Go need openLink with the Expo URL instead of launchApp. Our guide on Maestro vs Detox compares the two for React Native teams.
For Flutter, Maestro reads the Semantics tree Flutter builds for accessibility, so nothing changes in pubspec.yaml. Widget Key values do not appear in that tree, which catches many Flutter developers out on day one. The Flutter page suggests a semanticLabel, a Semantics wrapper, or Semantics(identifier: "cart_icon") on Flutter 3.19 and later. For a side by side look at Patrol and integration_test, read our piece on Maestro for Flutter testing.
How do you run Maestro tests in CI?
Most teams take one of two routes when wiring Maestro into a pipeline. The first stays on infrastructure you own: a GitHub Actions, GitLab or Jenkins job boots an Android emulator (or an iOS Simulator on a macOS runner), installs the build and calls the CLI. Adding --format junit --output report.xml produces a per-test report your CI can display, and --format html gives a readable page to attach as an artifact, as the reports and artifacts page describes.
The second route hands device management to Maestro Cloud, which requires a Cloud plan. Your pipeline runs maestro cloud with --api-key, --project-id, --app-file and --flows, and the Flows are spread across hosted devices. Ready-made integrations exist for GitHub Actions, Bitrise, Bitbucket Pipelines and CircleCI, and a GitHub pull request check can block a merge when a Flow fails. Android uploads must be an APK (not an AAB) that runs on ARM, and iOS uploads must be a simulator .app build.
Three ways to run Maestro tests
| Option |
Where it runs |
Devices |
Good for |
| Maestro Studio |
A desktop app on a developer's Windows, Mac or Linux machine |
A local emulator, a local iOS Simulator, or a USB-connected Android phone |
Picking selectors visually and debugging a Flow that fails on one screen |
| Maestro CLI |
A laptop terminal or a self-hosted CI runner |
Android emulators, physical Android phones and iOS Simulators |
Pull request runs on your own runners with JUnit or HTML reports |
| Maestro Cloud |
Maestro's hosted infrastructure on a Cloud plan |
Hosted virtual Android devices and iOS Simulators |
Spreading a large suite across many devices from GitHub Actions, Bitrise, Bitbucket or CircleCI |
Either way, the habits in our guide to running mobile tests in CI/CD still apply: pin emulator images, keep smoke Flows on every pull request and push the long regression set to a nightly job. If suite time becomes the bottleneck, parallel testing explains how splitting runs across devices changes the math.
Where do Maestro flows get costly to maintain?
Credit where it is due: a new team can install Maestro, write a sign-in Flow and watch it pass on an emulator within an afternoon, and the YAML stays readable for people who never open the source. The cost shows up later, and it comes from ownership rather than from the tool.
Two separate costs grow together as the suite and the app age. Coverage is the first, because a Maestro suite only checks journeys somebody wrote, so a referral screen shipped last sprint has no test until someone adds one. Upkeep is the second, because each Flow carries selectors, platform branches and data assumptions that drift with every release.
What breaks a Maestro flow, and who fixes it
| What changed |
What happens |
Who fixes it |
| A button label is reworded or the app ships in German |
Text selectors like tapOn: "Sign in" stop matching |
QA edits the Flow, or developers add a stable id |
| A screen has no testID, accessibilityIdentifier or Semantics identifier |
Flows fall back to text or screen coordinates |
Developers add ids in the app code |
| A new journey, such as gift cards, goes live |
No Flow exercises it at all |
Someone writes and reviews a new Flow |
| Android and iOS layouts differ on the same screen |
The Flow needs when: platform branches |
QA maintains both branches over time |
| One Flow relies on data created by another |
Unordered runs fail at random |
Set executionOrder in the workspace config or make each Flow independent |
| A slow emulator or a throttled staging API |
Default waits expire before the screen loads |
Swap in extendedWaitUntil with a longer timeout |
None of this makes Maestro a poor choice for your team. It means the coverage map and the maintenance budget are yours, and both grow with every screen you add. Approaches such as self-healing test automation target the selector half, while the coverage half needs some way to find journeys nobody has written yet.
How does QApilot complement Maestro testing?
Keep Maestro for what it does well, which is fast, readable Flows on every pull request that developers and QA can both edit. QApilot works on the built app beside that suite and beside your device cloud. It does not import Maestro YAML, so the two simply run side by side.
On Android, the QApilot crawler explores the installed APK on its own and builds a knowledge graph of app flows and screens, which becomes test cases for journeys nobody wrote a Flow for (autonomous testing). CoWork turns written test cases into recordings a reviewer can check step by step. The crawler and CoWork are Android only today, with iOS coming soon.
On both Android and iOS, you can record and play back journeys locally or on a cloud provider from an APK or IPA, and share test cases across platforms with platform-specific steps where screens differ. When a locator breaks after a small UI change, AI auto-healing tries the element ID or accessibility ID, then XPath and tag attributes, then a visual match, then a coordinate fallback. Healed steps are flagged in the report, and a person approves each fix with Update XPath. Test plans can run from your pipeline, a cloud provider connection supports one simultaneous session by default, and you can export a plan as Appium code. For a feature by feature breakdown of the two tools, see QApilot vs Maestro.