· 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:
- One list of known interruptions. A new dialog in the app means one edit, not twenty.
- Clean test files. The flows that test features stay linear.
- An easy audit. If the subflow grows past a handful of entries, the app has too many interruptions, and that is worth a product conversation.
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
- Before writing a
whenblock, try to remove the need for it withclearStateand launch permissions. - Never put the feature under test inside a condition. A skipped block reports green.
- Keep every known interruption in one subflow, with stable
idselectors. - Follow each conditional with an assertion on the screen you expect.
- Use
platformfor real platform differences, and split the flow when the branches grow.