· 6 min read

Flaky Maestro Tests: Wait for App State, Not for Time

Why fixed sleeps make Maestro tests flaky, and how assertions, extendedWaitUntil and a clean app state give you flows that pass for the right reasons.

Flaky Maestro tests usually have the same root cause, and it is not Maestro. It is a flow that waits for time instead of waiting for state. Somebody adds a two-second pause after login because the home screen was slow once. It works on their laptop. On a busy CI emulator, two seconds is not enough, and the flow fails on a working app. This post shows how I replace fixed waits with waits on state, and how a clean start removes the other big source of random failures.

Why a fixed wait is always wrong

A fixed wait guesses how long the app needs. The guess is wrong in one of two directions.

If the guess is too short, the test fails when the device is slower than yours. CI emulators share CPU with other jobs, the first launch after install is slower, and network calls take as long as they take. The failure looks random, so people re-run the job until it passes.

If the guess is too long, the test passes, but every run pays for the pause. Ten pauses of three seconds in a suite of twenty flows add up fast. Worse, a long pause hides real problems. If the screen took eight seconds to appear, you want to know.

The fix is the same in both cases. Do not ask "how long should I wait?". Ask "what should be on screen when the app is ready?", and wait for that.

Maestro already waits for you

The first thing to know is that many waits you write by hand are not needed at all. Maestro's commands wait on their own. tapOn looks for the element and retries for a while before it gives up. assertVisible and assertNotVisible poll the screen until the condition holds or a timer runs out, and continue the moment it holds.

So this flow, which I have seen in many projects:

- tapOn:
    id: "login_submit_button"
- waitForAnimationToEnd
- assertVisible:
    id: "home_screen"

does not need the middle line in most cases. assertVisible will wait for the home screen by itself. The Maestro docs on wait commands put the rule simply: if you expect an element to appear or disappear within a short time, use an assertion.

This also changes how I read a flow. An assertion after an action is not only a check. It is the synchronization point. The flow does not move on until the app reaches the state the assertion describes.

Use extendedWaitUntil for the slow parts

The built-in waiting has a limit of a few seconds. That covers most screens. It does not cover the steps that are slow on purpose: the first sync after login, a file upload, a purchase sheet, a long list that builds from a local database.

For those, Maestro has extendedWaitUntil. It takes a selector and a timeout in milliseconds, and it finishes as soon as the condition is true:

- tapOn:
    id: "sync_now_button"
- extendedWaitUntil:
    visible:
      id: "sync_done_banner"
    timeout: 20000

Unlike a fixed pause, the 20 seconds is a ceiling, not a cost. On a fast run the flow continues after the banner appears. On a slow run it gets the time it needs. Only a real failure uses the whole timeout.

It also works the other way. Often the honest signal is not "something appeared" but "the spinner went away":

- extendedWaitUntil:
    notVisible:
      id: "loading_indicator"
    timeout: 15000
- assertVisible:
    id: "country_list"

Pick timeouts on purpose

It is tempting to set every timeout to a minute and never see a timeout failure again. The Maestro docs warn against this too, and I agree. A huge timeout turns a performance regression into a slow green test.

My rule: use the default assertion for screens that should be fast. Add extendedWaitUntil only for steps you know are slow, and give each one a timeout that would surprise you if it were reached. When a timeout does fail, treat it as a finding about the app, not about the test.

Give the app something to wait for

Waiting on state only works if the state is visible to Maestro. A spinner with no id, or a "done" state that looks the same as the "loading" state, gives the flow nothing to hold on to.

So I add ids to the moments that matter: the loading indicator, the empty state, the error banner and the root of each screen. In Flutter that means Semantics(identifier: ...), which I wrote about in Maestro Tests for Flutter: Use Semantics Identifiers. A test id on the loading indicator costs one wrapper widget and removes a whole class of guesses.

Start every flow from a known state

The second big source of flakiness is leftover state. One flow logs in and leaves the session. The next flow expects the login screen, and finds the home screen instead. Run them in a different order and both pass. That is the worst kind of failure, because it never reproduces when you look at it.

The fix is to make every flow start from scratch:

appId: com.example.app
---
- launchApp:
    clearState: true
- assertVisible:
    id: "welcome_screen"

clearState: true wipes the app's data before launch, so the flow does not depend on what ran before it. Then the first assertion confirms the starting point. If the app opens somewhere unexpected, the flow fails on line two with a clear message, not ten steps later with a confusing one.

Shared steps, like logging in, go into a subflow that each test calls with runFlow. Each test then sets up the state it needs instead of borrowing it from the previous test.

For offline-first apps this matters even more. If the app keeps data on the device, a dirty start means the test sees cached data from an earlier run. It may pass for the wrong reason: the list is full because of yesterday's sync, not today's.

What I avoid

A few habits look like fixes but make the suite worse over time.

Takeaways