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 coordinates
  • text — 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-LOGIN is 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.

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, an accessibilityIdentifier on 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 use text there.
👍 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, so index: 0 is the first match, index: 1 the 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:

  1. Match something only that element has. This is what Article 1 did: no other element on the screen has the label button-LOGIN.
  2. Describe where the element sits — for example, "the button below the Password field." That's a relational selector, covered in the next section.
  3. Use index. It works, but it's fragile. index: 1 means "the second match on the screen right now." If a banner appears above the form in the next release, index: 1 quietly 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.99 and 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: square finds elements whose width and height are almost equal, which is true of most icons. Combined with a relational selector, traits: square + rightOf: Home means "the square icon to the right of Home." traits: long-text finds a block of 200+ characters, like a terms and conditions screen.
Dimensions match an element by its size in pixels (width, height), with an optional tolerance for 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: algorithm

runFlow takes four parameters:

  • file — the flow to run.
  • commands — lets you write the steps directly inside runFlow instead 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 with commands, 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/tests

Now 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.yaml
0:00
/0:27

Both tests should pass — the failed login test passes because the error message it's looking for appears.

💡 You only pass APP_ID once on the command line. A subflow sees the parameters of the flow that called it which is why common/login.yaml can use ${APP_ID} in its header without the test passing it explicitly. EMAIL and PASSWORD are different: they come from each test's env block, 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.yaml and reused them in two tests
  • Organized your files into common/ (reusable steps) and tests/ (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.