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:
- Immediately atttempts to find the first widget with the
addComment
key - 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.
-
If something goes wrong and
addComment
never shows up, we'll keep waiting indefinitely. -
The widget with
addComment
key might be present in the widget tree, but still not be visible to the user. By default, Flutter's defaultWidgetTester
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:
- 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. - 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:
- Waits for at least 1 Scrollable widget (or whatever you provided in
view
argument) to become visible - Scrolls this widget in its scrolling direction until the target widget becomes visible
- 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.