Mobile E2E testing with Maestro: from zero to green flows
Maestro describes mobile E2E tests as YAML instead of code. What installation, the first flow, flow organization, and CI integration look like — plus three lessons from testing our own apps.

Why mobile E2E testing is so unpleasant
On the web, end-to-end testing is a solved problem. One browser, one DOM, one selector — Playwright or Cypress attaches and runs.
On mobile, none of that holds. There are two platforms with two entirely different UI toolkits. There is no stable DOM, just a view hierarchy that looks different depending on the renderer. There are animations that take 300 milliseconds and network requests that take 80 milliseconds one time and 2,000 the next. There are system dialogs for notifications, camera, and location that appear on top of the app and that nobody accounted for in the test code. And there is the simulator, which simply behaves differently on the second run than on the first.
Every team that has tried this with Appium or Detox knows the outcome: a test suite that grows half as fast as the app and is red twice as often as it deserves to be. Eventually nobody looks at it anymore, and at that point the suite is dead.
The actual failure is rarely in the test logic. It is in the amount of code you have to write before you can even express the first test case.
What Maestro does differently
Maestro is an open-source testing framework for mobile apps, and it makes three decisions that make the difference.
Tests are YAML, not code. A flow is a list of commands. No test runner, no page objects, no promise chains. Anyone who can operate the app can read the flow — including product owners and QA colleagues without a JavaScript background.
Black box instead of instrumentation. Maestro talks to the app from the outside, through the operating system's accessibility layer. Nothing has to be built into the app code, no test build, no bridge. The flow runs against the same build that ships to the store.
One language for iOS and Android. The same flow runs on both platforms as long as the texts and accessibility IDs match. With React Native and Flutter apps, that is almost always the case.
Then there is the point that matters most day to day: Maestro waits on its own. Every command has implicit waits built in. You don't write waitForElement because it's the rule, only where a real delay exists. That eliminates the single most common cause of flakiness — the forgotten wait.
Installation
Maestro installs as a CLI, with no project setup:
curl -Ls "https://get.maestro.mobile.dev" | bash
maestro --version
From here you only need a running simulator, emulator, or a USB-connected device with the app installed. A flow starts with:
maestro test .maestro/smoke/app-launch.yaml
Also useful when getting started is maestro studio: an inspector that shows the current view hierarchy and emits matching selectors directly as YAML. That tells you whether an element is reachable via id, text, or label — instead of guessing.
The first flow
A flow consists of a header — app ID, optional tags and name — and below it, separated by ---, the list of commands.
# Login with test credentials
appId: com.example.app
tags:
- auth
- smoke
name: "Login with test credentials"
---
- launchApp:
clearState: true
permissions:
notifications: allow
- tapOn:
id: "email-input"
- eraseText: 50
- inputText: "qa-user@example.com"
- tapOn:
text: "Your password"
- inputText: "Example-Password!1"
- tapOn:
text: "Sign in"
- assertVisible:
text: "Dashboard"
That is the complete test case. Three details are worth pointing out:
clearState: true resets the app to a factory state before launch. That is the difference between a test that always runs the same way and a test that depends on whatever the last run left behind. permissions answers the notification system dialog up front — one fewer dialog blocking the flow.
Selectors come in three flavors. id targets the accessibility ID and is the most stable — if the app sets one. text matches visible text, including placeholders in input fields, and accepts regular expressions. label targets the accessibility label, which for icon-only buttons is often the only way in.
For multilingual apps, the regex capability of text is worth a lot. Instead of maintaining two flows per language, you write one:
- extendedWaitUntil:
visible: "Dashboard|Übersicht|Overview"
timeout: 15000
extendedWaitUntil is the explicit wait for the cases where the implicit ones aren't enough: the first launch after installation, a sync against a slow API, a cold start on a cold simulator.
Assertions, screenshots, and flow organization
For checks there are assertVisible and assertNotVisible. Both accept optional: true — a missing element then produces a warning instead of a failure. That sounds like a fig leaf, and for certain test cases it is exactly right: a flow that checks a state not every test environment has should be skipped rather than turn the whole suite red. Combined with label, the warning becomes a readable statement:
- assertVisible:
text: "No imports yet|Noch keine Importe vorhanden"
optional: true
label: "Empty state expected when no imports recorded"
takeScreenshot writes a screenshot to a path of your choosing. That is more than a debugging aid: if you need store screenshots for several languages and device sizes anyway, you produce them with the same flows that run the tests. The side effect is that the screenshots never go stale.
- takeScreenshot: docs/screenshots/01-login
The biggest lever for a suite that is still alive a year later, though, is organization. What works is a structure of thematic folders plus one folder for reusable building blocks:
.maestro/
├── flows/ # building blocks: _launch-app, _ensure-logged-in, _logout
├── smoke/ # app launches, basic navigation
├── auth/ # login, registration, logout
├── navigation/ # tab switching, deep links
├── import/ # functional area
├── export/ # functional area
└── screenshots/ # store assets per language
Files in flows/ start with an underscore because they are not standalone tests but building blocks. They are pulled in with runFlow:
- runFlow: ../flows/_launch-app.yaml
- runFlow: ../flows/_ensure-logged-in.yaml
runFlow can also execute conditionally, which is handy for setup steps:
- runFlow:
when:
visible: "Dashboard"
file: ../flows/_logout.yaml
This way the launch sequence — including every dialog that can show up on the way to the first screen — exists exactly once in the repo. When onboarding changes, you change one file instead of forty.
tags let you group flows across the folder structure: smoke, regression, requires-login. The pipeline then runs the matching selection per trigger.
CI integration
The basic principle is simple: Maestro is a CLI call against a running device. The effort in the pipeline isn't Maestro — it's the device.
On a macOS runner you start an iOS simulator headless, install the build, and invoke the suite:
maestro test .maestro/smoke .maestro/auth
maestro test .maestro/import
For Android the same applies with an emulator. The exit code determines build status, and JUnit reports can be emitted for the CI UI.
In practice, staggering pays off: smoke flows on every push, the functional folders overnight. Mobile E2E tests are slow — a flow takes between 20 seconds and three minutes depending on scope. Hanging the full suite on every commit makes the pipeline unusable, which creates the next reason to switch it off.
Two things Maestro doesn't include still belong in the calculation. First, test data: a flow that verifies an import needs a file with defined content — exactly the case we built Nanook for, because systematic test data is hard to maintain by hand. Second, time: anything depending on dates, billing periods, or expiry deadlines can't be meaningfully checked through UI interaction. That calls for time-driven E2E testing like bitdiver, which operates on a different level than the flow on the device.
Three honest lessons
We test our own apps — TankKosten and LadeKosten — with Maestro. Three things cost us more to learn than they should have.
First: the launch is the hardest part of the flow. Not the test case. Between launchApp and the first genuinely checkable screen sit system dialogs, debug overlays in the development build, onboarding screens, and animations. We spent a long time handling that separately in every flow. The right answer is a single launch building block that clears each of those hurdles with optional: true — and then appears in every flow via runFlow.
Second: optional: true is a tool, not a bandage. We initially used it to turn red tests green. The result was a suite that was always green and said nothing. The rule that came out of it: a step may be optional when it depends on an environment state the test doesn't control. The actual assertion of the test case is never optional.
Third: text selectors break with every translation. Our apps run in several languages, and each new locale toppled flows built on German labels. Setting accessibility IDs for the important elements from the start avoids this. For everything else, a regex selector covering all language variants is the second-best solution — and the most pragmatic one when the app already exists.
Conclusion
Maestro doesn't solve every problem in mobile testing. It solves the problem that kills most suites: the gap between "we should test this" and "the test is in the repo." A flow is twenty lines of YAML. You write that even when the sprint is tight.
If you want to build E2E testing for your app — from the first suite to CI integration — our page on mobile E2E testing explains how we approach it.