Patrol finders - usage

This page introduces Patrol finder system. Let's get our hands dirty and find some widgets!

Finding widgets

Let's say you want to find some Text widget – nothing easier than that!

find.byType(Text);

Using Patrol finder, you'd write the above as:

$(Text);

Or let's find a Text widget with a specific text:

find.text('Subscribe');

Using Patrol finder, you'd write the above as:

$('Subscribe');

Worth mentioning is also Key. The below lines are equivalent:

find.byKey(Key('loginButton'));
$(Key('loginButton'));
$(#loginButton);

For those wondering what is that # thing – it's a Symbol! Yes, we're Dart (ab)users.

All the types that can be passed to $ are listed here.

Making assertions

Creating a finder doesn't do anything – it just is. Let's put them to use and write a few simple assertions.

Here's how you can make sure that a widget with text Log in exists in the widget tree:

expect(find.text('Log in'), findsOneWidget);

With our Patrol finders, you'd write the above as:

expect($('Log in'), findsOneWidget);

Alternatively, you could also use the exists getter, which returns true if the finder finds at least 1 widget:

expect($('Log in').exists, equals(true));

We can also make sure that no widget exists, or that a particular number of widgets exist:

expect(find.text("Can't touch this"), findsNothing);
expect(find.byType(Card), findsNWidgets(3));

The above expressed with Patrol finders:

expect($("Can't touch this"), findsNothing);
expect($(Card), findsNWidgets(3));

You could alternatively write the first line as:

expect($("Can't touch this").exists, equals(false));

It's important to note that Flutter's default finder functions, such as findsNothing and findsOneWidget, check if the widget is present in the widget tree, not if it is visible to the user, which is usually not what we're interested in.

To check if the finder finds at least 1 visible widget, use the visible getter:

expect($('Log in').visible, equals(true));

And to wait for at least 1 widget with the "Log in" text to become visible:

await $('Log in').waitUntilVisible();

Performing actions

Finding widgets alone is cool, but what's even cooler is being able to tap on them! Let's tap on the first "Subscribe" text:

await tester.tap(find.text('Subscribe').first);

It's usually a good practice to use first, because if there were multiple "Subscribe" texts, tap() would throw an exception.

With Patrol, you get concise code, but you preserve the flexibility:

await $('Subscribe').tap();

What's very cool about Patrol's tap() is that it doesn't immediately fail if the finder finds no visible widgets – instead, it waits for some time (which you can specify globally in PatrolTesterConfig or as argument to the tap() method) and taps on the first widget as soon as it becomes visible. This lets you get rid of fixed timeouts and test your app just like a real user would.

If you wanted to tap on the third "Subscribe" text, you'd do:

await $('Subscribe').at(2).tap();

And if the "Subscribe" text was in a Scrollable widget, such as SingleChildScrollView or ListView, and you want to make sure that it is visible (so you can tap() on it), you can scroll to it very easily:

await $('Subscribe').scrollTo().tap();

Going deeper

But hey, these were very simple examples. In real apps, unfortunately, finding widgets is not that easy.

Often, you'll need to tap on a widget which is in some other widget.

await tester.tap(
  find.descendant(
    of: find.byType(ListView),
    matching: find.text('Subscribe'),
  ).first
);

Flutter's finders are starting to grow, while Patrol stays lean:

await $(ListView).$('Subscribe').tap();

Now, we also make sure that the Subscribe text is in a ListTile:

await tester.tap(
  find.descendant(
    of: find.byType(ListView),
    matching: find.descendant(
      of: find.byType(ListTile),
      matching: find.text('Subscribe'),
    ),
  ).first
);

Hey, this is starting to look complex! Fortunately, you have Patrol:

await $(ListView).$(ListTile).$('Subscribe').tap();

Sometimes, you might want to perform a lookahead check. Let's say that you want tap on the first widget with the Key('learnMore') that is a descendant of some ListTile, but that ListTile must also have the Text descendant with the Activated text.

If you were to express the above as a Finder, you'd get:

await tester.tap(
  find.ancestor(
    of: find.text('Activated'),
    matching: find.descendant(
      of: find.byType(ListTile),
      matching: find.byKey(Key('learnMore')),
    ),
  ).first
);

With the help of Patrol's custom finders, it's much easier:

await $(ListTile).containing('Activated').$(#learnMore).tap();

Sometimes, however, the logic required to find a widget cannot be expressed by the descendant/ancestor relationship like above. In situations like this, when all ways of finding widgets known to you fail, Patrol has an ace up its sleeve: the which() method. You can use it to find widgets by their properties. A few examples include: .

  • entering a text into a text field with no text entered:

    await $(#cityTextField)
        .which<TextField>((widget) => widget.controller.text.isNotEmpty)
        .enterText('Warsaw, Poland');
    
  • asserting that the icon has the correct color:

    await $(Icons.error)
        .which<Icon>((widget) => widget.color == Colors.red)
        .waitUntilVisible();
    
  • asserting that the button is disabled and has the correct color

    await $('Delete account')
      .which<ElevatedButton>((button) => !button.enabled)
      .which<ElevatedButton>(
        (btn) => btn.style?.backgroundColor?.resolve({}) == Colors.red,
      )
      .waitUntilVisible();
    
    

Falling back

What's cool about Patrol is that it builds on top of flutter_test instead of replacing it. This means that you can freely mix Patrol's finders with finders from flutter_test, PatrolTester with WidgetTester, and so on.

Here's how you can access the default WidgetTester:

patrolWidgetTest('adds comment', (PatrolTester $) async {
  final WidgetTester tester = $.tester;

  await tester.enterText(find.byKey(Key('commentTextField')), 'Very nice!');
});