· 5 min read
Maestro Tests for Flutter: Use Semantics Identifiers
Why Maestro UI tests for Flutter break when they select widgets by visible text, and how Semantics identifiers give each widget a stable id to tap.
Most Maestro tests for Flutter start the same way: tapOn: "Sign in". It works on the first day. Then someone changes the button copy, or the test runs on a device set to German, and the flow fails although the app is fine. The fix for Maestro and Flutter is to stop selecting widgets by what the user reads and start selecting them by a Semantics identifier. This post shows how that works, what it costs, and where text selectors still make sense.
How Maestro sees a Flutter app
Maestro does not know about your widget tree. It reads the accessibility tree of the running app, the same one that TalkBack and VoiceOver use. Flutter builds that tree from its semantics layer.
This has two consequences:
- A
Keyis invisible to Maestro. Keys live in the widget tree and are meant forflutter_testandintegration_test. They never reach the accessibility tree. - Anything Maestro can find, it finds through semantics: the text of a
Textwidget, asemanticLabelon an icon or image, atooltip, ahintText, or an explicitSemanticswidget.
The Maestro docs list the details on the Flutter platform page. The short version: if a screen reader can find a widget, Maestro can too.
Why text selectors break
Selecting by text is the default because it needs no changes in the app. Here is a typical first flow:
appId: com.example.app
---
- launchApp
- tapOn: "Sign in"
- tapOn: "Email"
- inputText: "test@example.com"
- tapOn: "Continue"
- assertVisible: "Welcome back"
It reads well. It also depends on four strings that have nothing to do with the behavior under test. These are the things that break it:
- Copy changes. "Continue" becomes "Next". No logic changed, but the test is red.
- Localization. The flow passes on an English device and fails on every other language. You either pin the device language or keep one flow per language.
- Duplicates. Two "Save" buttons on one screen, or a title and a button with the same word. Maestro picks one, and it may not be the one you meant. You end up adding
index: 1, which breaks the next time the layout changes. - Widgets with no text. An icon button, a map, a custom painted control. There is nothing to type into the selector.
The pattern is the same in all four cases. The test fails for a reason the user would not call a bug. After a few such failures people stop trusting the suite, and a suite nobody trusts does not get run.
Semantics identifiers in Flutter
Flutter has a field made for this. The Semantics widget takes an identifier, added in Flutter 3.19. The API docs describe it as a unique identifier for the semantics node that is not shown to the user and is not read by screen readers.
On Android it becomes the resource-id of the node. On iOS it becomes the accessibilityIdentifier. Those are the same fields native apps use for UI tests, and Maestro matches both with its id selector.
Wrap the widget:
Semantics(
identifier: 'login_submit_button',
child: FilledButton(
onPressed: onSubmit,
child: Text(l10n.signIn),
),
)
Then use the id in the flow:
- tapOn:
id: "login_submit_button"
The button text can now change, or be translated, and the flow does not care.
The earlier flow becomes:
appId: com.example.app
---
- launchApp
- tapOn:
id: "welcome_sign_in_button"
- tapOn:
id: "login_email_field"
- inputText: "test@example.com"
- tapOn:
id: "login_submit_button"
- assertVisible:
id: "home_screen"
It is a little longer. It also runs the same way in every language.
Keep labels and identifiers apart
There is a shortcut that looks tempting: put the test id into Semantics(label: ...). Do not do it. The label is what a screen reader says out loud. A user with VoiceOver would hear "login underscore submit underscore button". The identifier field exists so that tests and accessibility do not fight over the label.
Rules that keep ids useful
Identifiers only help if they are stable and easy to guess. These are the rules I follow.
Name by screen and role. I use screen_element_type: login_email_field, settings_language_row, map_share_button. Someone writing a flow can often guess the id without opening the code.
Keep ids in one place. A plain Dart class of constants is enough:
abstract final class TestIds {
static const loginEmailField = 'login_email_field';
static const loginSubmitButton = 'login_submit_button';
static const homeScreen = 'home_screen';
}
A typo in a string literal fails silently until the test runs. A constant fails at compile time. A single file also shows which screens have test hooks and which do not.
Give each screen a root id. One Semantics(identifier: 'home_screen') around the screen body gives every flow a reliable "did we arrive" check. It replaces assertions on a title that may be reworded.
Build ids for list items from data. For a list, use something like 'country_row_${country.code}'. Do not use the position in the list. Sorting changes; the code does not.
Add ids only where a flow needs them. You do not need to tag every widget. Start with one flow, add the ids it touches, and move on. The cost is a wrapper widget per element, so spend it where a test exists.
A small helper keeps the call sites short:
extension TestIdX on Widget {
Widget testId(String id) => Semantics(identifier: id, child: this);
}
Now a button becomes FilledButton(...).testId(TestIds.loginSubmitButton).
Check what Maestro actually sees
When a selector does not match, do not guess. Run maestro studio or maestro hierarchy against the running app and look at the tree. If the id is missing, the usual causes are an old Flutter version, a parent that merges or excludes semantics (MergeSemantics, ExcludeSemantics), or a widget that is simply not on screen yet.
When text selectors are still right
Ids are not a replacement for every text check. Sometimes the text is the thing under test.
- The copy matters. A price label, an error message, a legal notice. Assert on the text, because a wrong string here is a real bug.
- A localization smoke test. One short flow per language that opens the main screens and checks a few known strings. Here the language dependency is the point.
- System dialogs. Permission prompts belong to the OS. You cannot add identifiers to them, so text is what you have.
My rule of thumb: navigate by id, assert by id when checking state, and assert by text only when the words themselves are the requirement.
Takeaways
- Maestro reads the accessibility tree, so Flutter
Keys do not help. Only semantics do. - Text selectors tie tests to copy and language. They fail for reasons that are not bugs.
Semantics(identifier: ...)gives a widget a stable id that users and screen readers never see. Maestro matches it withid:.- Keep ids in one constants file, name them by screen and role, and add them only where a flow needs them.
- Keep text assertions for the cases where the text is the requirement.