· 5 min read

Maestro Conditional Flows: Handle Optional Screens Safely

How to use Maestro conditional flows for screens that only sometimes appear, without hiding real bugs behind runFlow when blocks.

Some screens in a mobile app only show up sometimes. An onboarding carousel on first launch. A "what's new" sheet after an update. A system permission dialog on one Android version but not another. Maestro conditional flows let a test step around these screens with runFlow and a when block. They are useful, and they are also the easiest way to write a test that passes while the app is broken. This post is the set of rules I use so conditionals handle noise and never hide a bug.

What a Maestro conditional flow looks like

A conditional in Maestro is a runFlow with a when block. If the condition is true, the commands run. If it is false, Maestro skips them and moves on. The conditions docs list four kinds: visible, notVisible, platform and true (a JavaScript expression).

- runFlow:
    when:
      visible: "What's new"
    commands:
      - tapOn: "Got it"

You can point at a file instead of inline commands, and you can combine conditions. When a when block has more than one condition, all of them must be true:

- runFlow:
    when:
      platform: Android
      visible: "Allow notifications?"
    file: subflows/dismiss-notification-prompt.yaml

That is the whole feature. The hard part is deciding where it belongs.

Rule 1: control the state before you branch on it

Most optional screens are optional because of state: first launch or not, a flag on or off, a permission granted or not. If the test can set that state, it should. Then there is nothing to branch on.

Two tools cover most cases. clearState: true on launchApp gives you a clean install every time, so first-launch screens always appear and you handle them the same way every run. And permissions can be set at launch:

- launchApp:
    clearState: true
    permissions:
      all: allow

Per the permissions docs, Maestro grants all permissions by default, and you can override that with allow, deny or unset, either at launch or later with setPermissions. So a test that checks the "permission denied" path should say deny on purpose, not wait to see whether a dialog appears.

The question I ask before writing any when block: "Can I make this screen always appear, or never appear?" If yes, I do that and write plain, linear steps. A linear flow is easier to read and fails in a clear place.

Rule 2: never put the thing under test inside a condition

This is the rule that matters most. Here is a test that looks fine, where the share button is the feature under test:

- runFlow:
    when:
      visible: "Share map"
    commands:
      - tapOn: "Share map"
      - assertVisible: "Share card ready"

If a bug removes the share button, the condition is false, Maestro skips the block, and the flow passes. The test now proves nothing, and it reports green.

A condition is for something the test does not care about: an interruption between the start and the thing you are checking. The feature itself must be a plain step that fails when it is missing:

- tapOn:
    id: "share_map_button"
- assertVisible:
    id: "share_card_preview"

A quick check I run on review: delete the feature's button in a local build and run the flow. If it still passes, a condition is swallowing it.

Rule 3: put all interruptions in one subflow

When an interruption really is out of your control, handle it in one place. I keep a single subflow, subflows/dismiss-interruptions.yaml, and call it at the points where these screens can appear, usually right after launch. Here is subflows/dismiss-interruptions.yaml:

appId: com.example.app
---
- runFlow:
    when:
      visible:
        id: "whats_new_sheet"
    commands:
      - tapOn:
          id: "whats_new_close"
- runFlow:
    when:
      visible:
        id: "rate_prompt"
    commands:
      - tapOn:
          id: "rate_prompt_later"

Then the real flow reads like this:

appId: com.example.app
---
- launchApp:
    clearState: true
- runFlow: subflows/dismiss-interruptions.yaml
- assertVisible:
    id: "home_screen"

This gives you three things:

Note the selectors. Conditions check the screen right now, so they need stable, unique targets. I use semantics identifiers instead of visible text, for the same reasons I described in Maestro tests for Flutter: use semantics identifiers. Text changes with every translation; an id does not.

Rule 4: assert the state after the branch

A conditional block can do the wrong thing quietly. Maybe it tapped "Later" on a sheet that was not the one you meant. Maybe the sheet had not appeared yet when the condition was checked, and it shows up a second later on top of your next tap.

So every call to the interruptions subflow is followed by an assertion on the screen you expect. In the example above, that is assertVisible: home_screen. If an interruption slipped through, the flow fails right there, with a screenshot that shows the dialog, and not five steps later on a confusing tap.

For screens that appear with a delay, wait for the app to settle before the conditional runs. Assert something stable first, like the main screen's root, then check for the optional sheet. I covered why waiting for state beats waiting for time in flaky Maestro tests: wait for app state, not for time.

When platform conditions are fine

platform is the one condition I use without much worry. It does not depend on what is on screen, so it cannot hide a missing button. It is a good fit for real platform differences in one shared flow, like a system dialog that only exists on Android:

- runFlow:
    when:
      platform: Android
    file: subflows/android-back-to-home.yaml

Even here, keep it small. If the iOS and Android paths share less than half their steps, two separate flows are easier to read than one flow full of branches.

Takeaways