Flutter

Flutter is a first class citizen for Maestro. It can test both pure and hybrid (i.e add-to-app) Flutter mobile apps.

Interacting with widgets by semantics label

Maestro can interact with widgets that have semantics information attached. By default, this includes all widgets that display text (data in the Text widget, hintText in the TextField, and so on). You can also attach semantics information to any widget using Flutter's Semantics widget.

Example: Tap on a widget

Given an InkWell widget with a Text widget child:

InkWell(
  child: Text('Open Browser'),
  onTap: () => launch('https://mobile.dev'),
)

The following command will tap on it:

- tapOn: Open Browser

Some widget, such as Icon, don't have implicit semantics. In such cases you can often pass a semanticLabel:

FloatingActionButton(
  onPressed: _incrementCounter,
  child: Icon(Icons.add, semanticLabel: 'fabAddIcon'),
)

Then the FloatingActionButton can be interacted with using the following command:

- tapOn: fabAddIcon

The Icon widget simply creates a Semantics widget itself – see the source.


Finally, you can wrap any widget with Semantics:

Semantics(
  label: 'funky yellow box',
  child: Container(
    color: Colors.yellow,
    width: 100,
    height: 100,
  ),
)
- tapOn: funky yellow box

Example: Enter text in a widget

To enter text in the following text field widget:

TextField(
  decoration: InputDecoration(
    border: UnderlineInputBorder(), 
    labelText: 'Enter your username',
  ),
)

Use this command:

- tapOn: Enter your username
- inputText: charlie_root

Example: Assert a widget is visible

Text(
  'Welcome back, dear $fullName! 👋🎉',
  semanticsLabel: 'Welcome back, dear $fullName!',
),
- assertVisible: Welcome back, dear Test User!

In cases where both semanticLabel and text label are provided (like above), the semanticLabel takes precedence. It's recommended to use maestro studio in such cases to easily identify what label to use.

Interacting with widgets by semantic identifier

When your app grows, testing often becomes harder.

Maybe the app gets multi-language support, and now you have to decide on the language in which you test it. Maybe some of the strings displayed are non-static (e.g. becase of A/B tests). And the sheer number of screens makes tests harder to maintain.

When you start facing these problems, you should consider using the accessibility identifier instead of semantics labels.

This is a new feature that we contributed to Flutter to make it easier to test apps made with it. It's available on the stable channel since Flutter 3.19 (released on February 15th, 2024). To use it, upgrade to the latest stable Flutter release:

flutter channel stable
flutter upgrade

Example: Tap on a widget by semantics identifier

Semantics(
  identifier: 'signin_button',
  child: ElevatedButton(
    onPressed: _signIn,
    child: Text('Sign in'),
  ),
)
- tapOn:
    id: signin_button

Example: Enter text in a widget by semantics identifier

Semantics(
  identifier: 'username_textfield',
  child: TextField(
    decoration: InputDecoration(
      border: UnderlineInputBorder(), 
      labelText: 'Enter your username',
    ),
  ),
)
- tapOn:
    id: username_textfield
- inputText: charlie_root

Good practices

Let's say you have a FancyButton widget in your app. These buttons are important for you, and you want to ensure they always have an accessibility identifier assigned so they can be reliably interacted with using Maestro. The code sample below requires all callers of FancyButton to pass an accessibility identifier:

class FancyButton extends StatelessWidget {
 FancyButton({
    super.key,
    required this.identifier,
    required this.onPressed,
  });

  final String identifier;
  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return Semantics(
      identifier: identifier,
      child: RawMaterialButton(
        onPressed: onPressed,
        // ...
      ),
    );
  }
}

This also has the benefit of reducing widget nesting at the call site:

FancyButton(
  identifier: 'buy_premium',
  onPressed: _buyPremium,  
)

// instead of:

Semantics(
  identifier: 'buy_premium',
  FancyButton(
    onPressed: _buyPremium,
  ),
)

Of course, there's always danger of a developer accidentally not using the FancyButton widget and defering to the built-in ElevatedButton. To combat that, we recommend setting up lint rules that forbid using ElevatedButton and enforce replacing it with a FancyButton instead. For example you can use the leancode_lint package with the following configuration in analysis_options.yaml:

include: package:leancode_lint/analysis_options.yaml

custom_lint:
  rules:
    - use_design_system_item:
      FancyButton:
        - instead_of: ElevatedButton
          from_package: flutter
          
analyzer:
  plugins:
    - custom_lint

Why not Flutter keys?

Flutter widget keys cannot be used in Maestro because there's no linkage between widget keys and Flutter's accessibility bridge system. This makes using Keys impossible since Maestro is accessibility-tree based.

Also, Flutter API docs for the Key class and Widget.key field say it's for "controlling how one widget replaces another (e.g. in a list)". Keys are just not a mechanism for assigning unique IDs to widgets for testing purposes.

We strongly recommend making your app accessible (not just for UI tests, but for all of your users with different needs). When testing at scale, you should also consider using an accessibility identifier.

Here's also a little trick that you may find useful if you really want to use keys in Maestro (using the FancyButton example from above):

class FancyButton extends StatelessWidget {
  FancyButton({
    required String key,
    required this.onPressed,
  })  : _key = key,
        super(key: ValueKey(key));

  final String _key;
  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return Semantics(
      identifier: _key,
      child: RawMaterialButton(
        onPressed: onPressed,
        // ...
      ),
    );
  }
}

Callers are required to pass a string key:

FancyButton(
  key: 'unlock_reward',
  onPressed: _unlockReward,
)

And you can easily interact with the widget using Maestro:

- tapOn:
    id: unlock_reward

Known Limitations

Maestro cannot be used to test Flutter Desktop or Flutter Web apps (yet).

Last updated