Patrol finders - advanced

We aim to make Patrol as simple as possible, but there are still a few matters that we feel require some more attention. We'll explain them in this section.

How is Patrol's tap() different from Flutter's tap()?

Let's consider this test, written without Patrol:

await tester.tap(find.byKey(Key('addComment')).first);
await tester.pumpAndSettle();

This code:

  1. Immediately atttempts to find the first widget with the addComment key
  2. After finding the widget, it immediately attempts to tap on it

This is the default behavior, but in our experience, it's often a source of flakiness. For example, the widget having addComment key might not be visible at the time when the finder is run. This usually doesn't means that the test should fail. Probably an HTTP request was made to fetch the post, and when the fetching is done, the widget having addComment key will show up.

To achieve this behavior, you'd have to do:

while (find.byKey(Key('addComment')).first.evaluate().isEmpty) {
  await tester.pump(Duration(milliseconds: 100));
}

await tester.tap(find.byKey(Key('addComment')).first);
await tester.pumpAndSettle();

Our tiny example got really big, but it's still got two problems.

  1. If something goes wrong and addComment never shows up, we'll keep waiting indefinitely.

  2. The widget with addComment key might be present in the widget tree, but still not be visible to the user. By default, Flutter's default WidgetTester doesn't care. This is almost never desirable.

Fortunately, you don't have to overcome these problems. Patrol already did it!

Below is the same test, with all the above problems fixed, written with Patrol's custom finders:

await $(#addComment).tap();

This code:

  1. Attempts to find the first widget with addComment that is visible on screen. If it's not found immediately, it keeps trying until it finds it, or throws an exception if timeout.
  2. Taps on it.

The timeout can be configured globally:

patrolWidgetTest(
  'logs in successfully',
  config: PatrolTestConfig(findTimeout: Duration(seconds: 10)),
  ($) async {
  // your test code
  },
);

You can also change the timeout ad-hoc:

await $(#addComment).tap(findTimeout: Duration(seconds: 30));

You gotta pump it up! But which one to use?

In Flutter, "pumping" means rendering frames to the screen.

If there are no frames to pump, no animations are pending, which usually means that the next action during the test can be executed. It is an equivalent of what a human tester would do while testing an app - they would wait until the app's state stabilizes after they've done something. For example, they tap on a button and get redirected to another screen, but the data that will be shown there hasn't been loaded yet. In such a case, a human tester waits until a loader (or other animation) finishes. Pumping mechanism does exactly this - it renders consecutive frames on the screen. For how long, exactly? Usually, we want to pump frames as long as they come. That's what pumpAndSettle() does.

pumpAndSettle() method is called by default inside all actions that can be performed while testing - tapping, scrolling, entering text, and so on. You can change that by setting the settlePolicy argument:

await $('Delete account').tap(settlePolicy: SettlePolicy.settle);
await $('Confirm').tap(settlePolicy: SettlePolicy.pump);

SettlePolicy is an enum with 3 values. The default is SettlePolicy.settle but you can change it to pump or trySettle. Those values map to methods like this:

  • noSettle -> pump(),
  • trySettle -> pumpAndTrySettle(),
  • settle -> pumpAndSettle().

While settle and pump simply refer to Flutter's built-in methods, trySettle is available only in Patrol. How is it different from other ones?

pumpAndTrySettle() is pretty much like pumpAndSettle(), the only difference is that pumpAndSettle() throws an exception, if there were still new frames to render after sonme defined timeout, while pumpAndTrySettle() does not. That's why it has "try" in it's name.

When to use this new pumping method? Let us picture a scenario, in which we have to deal with some animations. Let's say, that your app has some endless animations, e.g. on a homescreen, to keep user's attention. You'd like to wait for some things to happen, but using pumpAndSettle, you'll keep getting an exception, because after some time, defined by timeout, there will be still new frames to render. On the other hand, you still want to pump frames for some time - if you didn't, the screen you want to interact with might not be rendered yet, or it would have some widgets missing or data not yet loaded.

So, we decided to add a way to try settle - pump frames for some time (10 seconds by default), but if after that time there is still something new to render - do nothing and continue the test.

We recommend using pumpAndTrySettle(), because it works with both kinds of animations - finite and infinite. This settle policy will be new default in future Patrol releases.

How does scrollTo() work?

The scrollTo() method is simple to use, yet very powerful. Here's how you use it to scroll to and tap on the first widget with the "Delete account" text:

await $('Delete account').scrollTo().tap();

And here's how scrollTo() works:

  1. Waits for at least 1 Scrollable widget (or whatever you provided in view argument) to become visible
  2. Scrolls this widget in its scrolling direction until the target widget becomes visible
  3. If the target widget becomes visible within timeout, it finishes, otherwise it throws an exception

Most of the time, you use scrollTo() and it just works, but there's 1 important thing to keep in mind when using scrollTo():

scrollTo(), by default, scrolls the first Scrollable widget

This default is reasonable and what you want most of the time. Unfortunately, this behavior can sometimes cause problems in more complicated UIs, where more than a single Scrollable widget is visible at the same time. In such cases we strongly recommend explicitly specifying the view that scrollTo() should scroll, to avoid the problem of the target widget never becoming visible because the wrong widget was scrolled.

To demonstrate this problem, let's consider this very simple app:

class App extends StatelessWidget {
  App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Column(
          children: [
            Expanded(child: ListView(key: Key('listView1'))),
            Expanded(
              child: ListView.builder(
                key: Key('listView2'),
                itemCount: 101,
                itemBuilder: (context, index) => Text('index: $index'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

Now let's say that you're writing a test and want to scroll to and tap on the first widget with the "index: 100" text (that is the last Text widget built by the second ListView widget):

There's a high chance that you'd write this:

await $('index: 100').scrollTo().tap();

Unfortunately, running this test gives the pumpAndSettle timed out error. That's because the scrollTo() was trying to scroll the first visible Scrollable widget, which happens to be the first ListView (the one with listView1 key and no children).

To fix this problem, you have to explicitly specify which Scrollable you want to use:

await $('index: 100').scrollTo(view: $(#listView2).$(Scrollable)).tap();

The above snippet will scroll the second Scrollable and find the widget with "index: 100" text.

Why so verbose?

You might be wondering why scrollTo(view: $(#listView2)) is not enough? Why is it needed to look for a Scrollable widget inside the widget with the listView2 key?

This is because the ListView widget doesn't extend Scrollable – instead, it builds a subclass of Scrollable itself. This is a known Flutter problem.