In Skip Appium's Setup: A Passing Mobile Test in Minutes (Android & iOS), you installed Maestro and wrote a login.yaml flow that logs in to the WebdriverIO Native Demo App on both Android and iOS.
That flow works, but it won't scale. The login steps live inside that one file, so every new test that needs a logged-in user starts with copying them — Android/iOS LOGIN button handling included. Fine for two tests. With twenty, one change to the login screen means twenty edits - and missing one leaves a test failing for no obvious reason.
This article fixes that and covers the selector and condition topics Article 1 left for later. It's the first of two parts. By the end of this one, you'll know how to:
- Target elements when visible text isn't enough
- Write the login steps once and reuse them in every test
Selectors: Beyond Visible Text
Matching on visible text - tapOn: "Login" - is the most readable way to write a flow. It's also where most selector problems start.
You already ran into one in Article 1. On the demo app's Login screen, three elements match "Login": the bottom tab, the Login/Sign up toggle, and the submit button (text matching ignores case, so "LOGIN" counts too). Maestro taps the first one it finds and reports success - even if it's the wrong one.
Visible text also lets you down when:
- The text changes between runs - a timestamp, a username, an order number
- The element has no text at all - an icon button or an image
- The app is translated — "Login" becomes "Connexion" in French and your flow stops matching
Maestro has a full selector system for exactly these cases.
Core selectors
- tapOn: Login # text — shorthand match
- tapOn:
text: ".*Continue.*" # text — regex partial match
- tapOn:
id: login_button # id — Resource ID (Android) / accessibilityIdentifier (iOS)
- tapOn:
id: buy_button
index: 2 # index — 0-based, when multiple elements match the same selector
- tapOn:
point: "50%, 50%" # point — relative percentage or absolute pixel coordinatestext— matches an element by its text. Usually that's the text you see on screen, but Maestro also checks a few other places an element can have text:- 🤖 Android: the content description - a text label developers attach to an element so a screen reader like TalkBack can read it aloud. It usually isn't shown on screen; the demo app's
button-LOGINis one. Maestro also checks the hint shown in an empty input field. - 🍎 iOS: the
accessibilityLabel. For standard buttons and labels, iOS sets it to the visible text automatically but developers can give an element a custom one - common on icon buttons.
- 🤖 Android: the content description - a text label developers attach to an element so a screen reader like TalkBack can read it aloud. It usually isn't shown on screen; the demo app's
You used text for almost everything in Article 1 — even the Email and Password fields, which matched on their placeholder text.
id— matches a hidden identifier the app's developers give an element: a Resource ID on Android, anaccessibilityIdentifieron iOS. App users never see it and it doesn't change when the visible text does, which makes it the most stable selector Maestro has. The catch: it only exists if developers added one. The demo app's LOGIN button has an identifier on iOS but not on Android — which is why Article 1 had to usetextthere.
👍 Text is the right place to start, as in Article 1: it's readable and works without any help from developers. But as your suite grows, prefer id wherever the app provides one - and ask your developers to add ids to the elements your tests depend on. It's a one-line change for them, and it makes your flows survive text changes and translations.index— picks one element when your selector matches several. Counting starts at 0, soindex: 0is the first match,index: 1the second, and so on.point— taps a spot on the screen rather than an element. You give it either percentages of the screen size ("50%, 50%"is the exact center) or exact pixel coordinates. Use it only when nothing else works - pixel coordinates in particular only fit one screen size.
💡 Note: percentages scale with the screen, so they're safer than pixel coordinates. But the element you're aiming for doesn't always scale the same way - different aspect ratios, tablet layouts and landscape mode can all move it away from that spot.
Back to the three "Login" matches from Article 1. You have three ways to tap the right one from best to worst:
- Match something only that element has. This is what Article 1 did: no other element on the screen has the label
button-LOGIN. - Describe where the element sits — for example, "the button below the Password field." That's a relational selector, covered in the next section.
- Use
index. It works, but it's fragile.index: 1means "the second match on the screen right now." If a banner appears above the form in the next release,index: 1quietly points at a different element - and Maestro taps it without complaint.
Use index only when nothing else tells the elements apart. If you've used Appium, it's the same rule as XPath indexes.
👍 Before you pick a selector, check what the element actually has - its text, its id, or both. In Maestro Studio, click the element; from the terminal, run maestro hierarchy --compact, as in Article 1. Guessing is how flows end up passing on one platform and failing on the other.Because text selectors are regular expressions, they match the whole text by default: tapOn: "Continue" finds a button labeled "Continue", but not one labeled "Continue to checkout." To match part of the text, add .* — regex for "anything" - before and after it:
- tapOn:
text: ".*Continue.*" # matches "Continue", "Continue to checkout", "Tap to Continue"This is also how you handle text that changes between runs: "Order #.*" matches both Order #1042 and Order #1043.
⚠️ Watch out: the regex behavior cuts both ways. Characters like$,.,(, and[have special meanings in a regular expression, so text that contains them may not match the way you expect. Prices like$99.99and counts in brackets like(3)are the usual culprits. Put a backslash in front of each special character, and wrap the text in single quotes so YAML leaves the backslashes alone:
- assertVisible: '\$99\.99'Relational selectors
Sometimes the cleanest way to describe an element isn't its own text or ID - it's where it sits relative to something else. This comes up more often than you'd think: an input field with no ID sitting under an "Email" label, an icon-only button next to a contact's name or one of ten identical "Delete" buttons in a list.
- tapOn:
below: Email
- tapOn:
leftOf: "I agree to the terms"
- tapOn:
rightOf:
id: input_text
- tapOn:
containsChild:
text: "Order 12345"
- tapOn:
text: Delete
childOf:
id: basket_container
- assertVisible:
id: list_item
containsDescendants:
- text: "Wireless Headphones"
- text: '\$99\.99'above/below/leftOf/rightOf— positional matching relative to another selector.containsChild— matches a parent element that has a specific direct child.childOf— matches an element that is a direct child of a given parent.containsDescendants— matches a container that has all the listed elements somewhere inside it, at any depth.
The ids in these examples (basket_container, list_item) are placeholders to show the syntax — the demo app doesn't have a shopping basket. Where these selectors really help is list screens. Imagine a shopping basket with ten products and every product has its own "Delete" button. If you write tapOn: "Delete", Maestro sees ten buttons with the same text — it can't tell which one you want.
The fix is to tell Maestro which row you mean. Each row has something unique in it, like the product name. So instead of saying "tap Delete," you say "tap the Delete button in the row that says Wireless Headphones." That's exactly what these selectors let you do.
💡 Note: Maestro has two more ways to find elements. You'll rarely need them, but they're good to know about:
Traits match an element by its shape or content type, not by what it says.traits: squarefinds elements whose width and height are almost equal, which is true of most icons. Combined with a relational selector,traits: square+rightOf: Homemeans "the square icon to the right of Home."traits: long-textfinds a block of 200+ characters, like a terms and conditions screen.
Dimensions match an element by its size in pixels (width,height), with an optionaltolerancefor a few pixels of wiggle room. Pixel sizes vary between devices, so treat these as a last resort.
If you're stuck on an element with no text, no ID, and no reliable neighbor, check Maestro's Element Traits and Dimension Matchers docs.
Subflows: Stop Repeating Yourself
A subflow is a reference to another flow file — or an inline block of commands — that you can call from multiple places instead of copy-pasting the same steps.
# Run a separate file
- runFlow: login.yaml
# Run with parameters
- runFlow:
file: anotherFlow.yaml
env:
MY_PARAMETER: "123"
# Run inline commands, no separate file
- runFlow:
label: Sort alphabetically
commands:
- tapOn:
id: sort_icon
- tapOn: algorithmrunFlow takes four parameters:
file— the flow to run.commands— lets you write the steps directly insiderunFlowinstead of putting them in a separate file. Good for a few steps you only use in one place.label— a short description, like "Log in as test user," that shows up in reports. Especially handy withcommands, since there's no file name to tell you what the steps do.env— a map of key-value pairs passed in as environment variables, read inside the subflow with${VARIABLE_NAME}.
Time to give your flows some structure. Inside the ~/maestro-demo folder from Article 1, create a flows folder with two subfolders — common for reusable steps and tests for the flows you actually run:
cd ~/maestro-demo
mkdir -p flows/common flows/testsNow take the login sequence from Article 1's login.yaml — everything between launchApp and the final assertion — and save it as flows/common/login.yaml, with the credentials swapped for variables:
# flows/common/login.yaml
appId: ${APP_ID}
---
- tapOn: "Login"
- tapOn: "Email"
- inputText: ${EMAIL}
- tapOn: "Password"
- inputText: ${PASSWORD}
- hideKeyboard
- runFlow:
when:
platform: Android
commands:
- tapOn: "button-LOGIN"
- runFlow:
when:
platform: iOS
commands:
- tapOn:
id: "button-LOGIN"The last two steps handle the LOGIN button which the demo app exposes as an accessibility label on Android (matched by text) and as an accessibility identifier on iOS (matched by id) — the selectors section above covers why. You'll meet when: platform: properly in the conditionals section below.
Now any flow that needs a logged-in state calls it with whatever credentials that test needs:
# flows/tests/successful_login.yaml
appId: ${APP_ID}
---
- launchApp:
clearState: true
- runFlow:
file: ../common/login.yaml
env:
EMAIL: "test@mobile-automation.io"
PASSWORD: "Password1234"
- assertVisible: "You are logged in!"# flows/tests/failed_login.yaml
appId: ${APP_ID}
---
- launchApp:
clearState: true
- runFlow:
file: ../common/login.yaml
env:
EMAIL: "test@mobile-automation.io"
PASSWORD: "short"
- assertVisible: "Please enter at least 8 characters"⚠️ Watch out: the demo app doesn't check passwords against a real account. As long as the email looks valid and the password is at least 8 characters, you're logged in. That means a wrong password like WrongPassword would still log you in, and this test would fail.What the app does check is the format of what you type. If the password is shorter than 8 characters, it shows Please enter at least 8 characters. That's why this flow uses short as the password and checks for that message. In a real app, you'd use a wrong password and check for the app's "incorrect password" message instead.
Run each test the same way you ran the parametrized flow in Article 1 — from ~/maestro-demo, passing the APP_ID for the platform:
# Android
maestro --platform android test -e APP_ID=com.wdiodemoapp flows/tests/successful_login.yaml
maestro --platform android test -e APP_ID=com.wdiodemoapp flows/tests/failed_login.yaml# iOS
maestro --platform ios test -e APP_ID=org.wdiodemoapp flows/tests/successful_login.yaml
maestro --platform ios test -e APP_ID=org.wdiodemoapp flows/tests/failed_login.yamlBoth tests should pass — the failed login test passes because the error message it's looking for appears.
💡 You only passAPP_IDonce on the command line. A subflow sees the parameters of the flow that called it which is whycommon/login.yamlcan use${APP_ID}in its header without the test passing it explicitly.PASSWORDare different: they come from each test'senvblock, so each test decides its own credentials.
The login steps exist in exactly one place. Change the flow once — a new field, a renamed button — and every test that logs in picks up the change automatically.
💡 Coming from Appium? Subflows solve the same problem as the Page Object Model: write the steps once, reuse them in every test, and fix them in one place when the app changes. The difference is what gets reused. A page object wraps a screen — its locators plus methods like login(). A subflow wraps an action — a sequence of steps like "log in." The trade-off: Maestro has no built-in place to store locators the way a page object does, so if two subflows tap the same button, both contain its selector. Most suites avoid this by giving each action a single subflow — but if you want one place for your selectors, you can build it yourself.👍 Building a shared selectors file: put your selectors in a JavaScript file:
// flows/common/selectors.js
output.selectors = {
emailField: "Email",
passwordField: "Password"
};Load it once in your test's header with onFlowStart (covered in the lifecycle hooks section below) and any subflow the test calls can use those values:
onFlowStart:
- runScript: ../common/selectors.js- tapOn: ${output.selectors.emailField}If a selector changes, you update one line in selectors.js, and every flow picks it up.
Quick-reference tables
Selector strategies
| Selector | Matches | Use when |
|---|---|---|
text |
Visible text, Android content description / hint, iOS accessibilityLabel (regex, case-insensitive) |
Starting point — readable, but watch for duplicate matches and regex characters |
id |
Resource ID (Android) / accessibilityIdentifier (iOS) |
Preferred when available — most stable; text is missing, changes, or is translated |
index |
The Nth match (0-based) | Multiple elements match the same selector |
point |
A screen coordinate | Last resort — breaks when layout shifts |
above / below / leftOf / rightOf |
Position relative to another selector | No unique text/ID, but a reliable neighbor exists |
containsChild |
A parent with a specific direct child | Targeting a container by what's inside it |
childOf |
A direct child of a given parent | Disambiguating repeated elements on a list screen |
containsDescendants |
A container with all listed elements inside, any depth | Verifying a card/row shows the right combination of content |
Subflows
| Command | Purpose | Example |
|---|---|---|
runFlow: file.yaml |
Run another flow file | - runFlow: login.yaml |
runFlow: { file, env } |
Run another flow with parameters | env: { EMAIL: "..." } |
runFlow: { commands } |
Run inline commands, no file | commands: [...] |
What's Next?
In this article, you:
- Learned how to pick a selector that taps the element you actually meant — not just the first one that matches
- Wrote the login steps once, in
common/login.yamland reused them in two tests - Organized your files into
common/(reusable steps) andtests/(the tests you run)
In Part 2, you'll turn those flows into a real test suite. You'll learn how to:
- Handle screens that only appear sometimes, like pop-ups
- Repeat steps without copying them
- Add setup and cleanup steps that run even when a test fails
- Get test data from an API inside a flow
- Run all your tests — or just some of them — with one command
💡 All the code from both parts is available as a free download at the end of Part 2. Subscribe for free so you don't miss it.
Discussion