This page covers advanced Patrol features for handling complex test scenarios that go beyond basic widget finding.
The which() Method
When descendant/ancestor relationships aren’t enough, use which() to find widgets by their properties.
Basic Usage
The which() method filters widgets based on a predicate function:
await $( ElevatedButton )
. which < ElevatedButton >((button) => button.enabled)
. tap ();
Real-World Examples
Find enabled buttons
// Tap on the first enabled button
await $( ElevatedButton )
. which < ElevatedButton >((button) => button.enabled)
. tap ();
Find widgets by color
// Find button with red background
await $( ElevatedButton )
. which < ElevatedButton >(
(btn) => btn.style ? .backgroundColor ? . resolve ({}) == Colors .red,
)
. tap ();
Find icons by color
// Assert error icon is red
await $( Icons .error)
. which < Icon >((widget) => widget.color == Colors .red)
. waitUntilVisible ();
Find text fields by content
// Enter text into empty field
await $( TextField )
. which < TextField >((field) => field.controller ? .text.isEmpty ?? true )
. enterText ( 'New text' );
Chaining Multiple Conditions
Chain multiple which() calls to combine conditions:
// Find enabled red button
await $( ElevatedButton )
. which < ElevatedButton >((button) => button.enabled)
. which < ElevatedButton >(
(btn) => btn.style ? .backgroundColor ? . resolve ({}) == Colors .red,
)
. tap ();
Complex Property Checks
// Find button by font size
await $( ElevatedButton )
. which < ElevatedButton >(
(button) => button.style ? .textStyle ? . resolve ({}) ? .fontSize == 20 ,
)
. tap ();
// Find disabled button with specific text
await $( 'Delete account' )
. which < ElevatedButton >((button) => ! button.enabled)
. which < ElevatedButton >(
(btn) => btn.style ? .backgroundColor ? . resolve ({}) == Colors .red,
)
. waitUntilVisible ();
The type parameter <T> in which<T>() is important—it ensures type safety and gives you proper autocompletion for widget properties.
The scrollTo() method is powerful but has important behavior to understand:
Waits for Scrollable
Waits for at least 1 Scrollable widget (or your custom view) to become visible
Scrolls until visible
Scrolls the widget in its scrolling direction until the target widget becomes visible
Succeeds or throws
If the target becomes visible within timeout, it succeeds. Otherwise, throws an exception
// Scroll to widget in first Scrollable
await $( 'Delete account' ). scrollTo (). tap ();
Important : By default, scrollTo() scrolls the first Scrollable widget found. This can cause problems when multiple Scrollables are visible!
Consider this app:
class App extends StatelessWidget {
@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 ' ),
),
),
],
),
),
);
}
}
This won’t work:
// ❌ Will timeout - scrolls wrong ListView!
await $( 'index: 100' ). scrollTo (). tap ();
The solution is to specify which Scrollable:
// ✅ Explicitly specify the second ListView
await $( 'index: 100' )
. scrollTo (view : $(#listView2).$( Scrollable ))
. tap ();
Why $(#listView2).$(Scrollable)? ListView doesn’t extend Scrollable—it builds a Scrollable widget internally. This is a known Flutter issue .
Customize scroll behavior with these parameters:
await $( 'Far item' ). scrollTo (
// Specify which Scrollable to use
view : $(#myScrollView).$( Scrollable ),
// Pixels to scroll per iteration
step : 128.0 ,
// Scroll direction (auto-detected by default)
scrollDirection : AxisDirection .down,
// Maximum scroll attempts
maxScrolls : 50 ,
// Time between scrolls
settleBetweenScrollsTimeout : Duration (seconds : 5 ),
// Drag duration per scroll
dragDuration : Duration (milliseconds : 100 ),
// How to pump frames
settlePolicy : SettlePolicy .trySettle,
// Where to check visibility
alignment : Alignment .center,
);
When widgets are partially visible, specify where to check:
// Check visibility at left edge
await $( ElevatedButton )
. scrollTo (alignment : Alignment .centerLeft)
. tap ();
Settle Policies
Settle policies control how Patrol pumps frames after actions.
Understanding Frame Pumping
In Flutter testing, “pumping” means rendering frames. After an action (like tapping), you need to pump frames to let the UI update.
The Three Policies
SettlePolicy.settle
SettlePolicy.trySettle
SettlePolicy.noSettle
// Pumps frames until none are pending
// Throws if timeout is reached
await $( 'Button' ). tap (settlePolicy : SettlePolicy .settle);
When to Use Each Policy
settle Use when : UI should stabilizeBest for : Most actions in apps without infinite animations
trySettle Use when : Infinite animations presentBest for : Apps with continuous animations, recommended as default
noSettle Use when : You need precise controlBest for : Testing mid-animation states
Infinite Animation Example
patrolWidgetTest (
'handles loading spinner' ,
config : PatrolTesterConfig (
settlePolicy : SettlePolicy .trySettle,
),
($) async {
await $. pumpWidgetAndSettle ( const AppWithInfiniteAnimation ());
// Won't throw even though spinner never settles
await $(#loginButton). tap ();
},
);
SettlePolicy.trySettle will be the default in future Patrol releases because it works with both finite and infinite animations.
How Patrol’s tap() Differs from Flutter’s
Patrol’s tap() is fundamentally different from Flutter’s default behavior.
Flutter’s Default Behavior
// ❌ Fails immediately if widget not visible
await tester. tap (find. byKey ( Key ( 'addComment' )).first);
await tester. pumpAndSettle ();
Problems:
Fails immediately if widget not found
Doesn’t check if widget is visible to user
Requires manual pumpAndSettle()
Patrol’s Smart Behavior
// ✅ Waits for widget to become visible
await $(#addComment). tap ();
Benefits:
Waits until widget is visible (with timeout)
Checks actual visibility, not just existence
Automatically pumps frames
The Traditional Way (Verbose)
// This is what you'd need without Patrol:
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 ();
The Patrol Way (Concise)
// All the above, in one line:
await $(#addComment). tap ();
Visibility vs Existence
Understanding the difference is crucial:
Existence
A widget exists if it’s in the widget tree, even if off-screen:
// Check existence (might be off-screen)
expect ($( 'Bottom item' ).exists, true );
// Wait for existence
await $( 'Background widget' ). waitUntilExists ();
Visibility
A widget is visible if it’s on-screen and hit-testable:
// Check visibility (must be on-screen)
expect ($( 'Bottom item' ).visible, false );
// Wait for visibility
await $( 'Bottom item' ). waitUntilVisible ();
If a widget is visible, it always exists. But an existing widget might not be visible!
Visibility with Alignment
Some widgets (like Row or Column) might be partially visible:
class Foo extends StatelessWidget {
@override
Widget build ( BuildContext context) {
return const Column (
children : [
Text ( 'Foo' ),
SizedBox (height : 48 ), // Empty space in center
Text ( 'Bar' ),
],
);
}
}
// This fails - center is empty
await $( Foo ). waitUntilVisible ();
// This works - checks top instead
await $( Foo ). waitUntilVisible (alignment : Alignment .topCenter);
Timeout Configuration
Control timeouts globally or per-action:
Global Timeout
patrolWidgetTest (
'test with custom timeouts' ,
config : PatrolTesterConfig (
visibleTimeout : Duration (seconds : 20 ),
existsTimeout : Duration (seconds : 15 ),
settleTimeout : Duration (seconds : 10 ),
),
($) async {
// All actions use these timeouts
},
);
Per-Action Timeout
// Override timeout for specific action
await $(#slowLoadingWidget). tap (
visibleTimeout : Duration (seconds : 30 ),
);
await $( 'Eventually appears' ). waitUntilVisible (
timeout : Duration (minutes : 1 ),
);
Mixing Patrol and Flutter APIs
Patrol builds on flutter_test, so you can freely mix both:
patrolWidgetTest ( 'mixed approach' , ( PatrolTester $) async {
// Use Patrol's pumpWidget
await $. pumpWidgetAndSettle ( const MyApp ());
// Use Flutter's semantics finder with Patrol syntax
await $(find. bySemanticsLabel ( 'Submit' )). tap ();
// Access WidgetTester directly
final WidgetTester tester = $.tester;
await tester. drag (find. byType ( ListView ), Offset ( 0 , - 200 ));
// Back to Patrol syntax
await $( 'Success' ). waitUntilVisible ();
});
Complete Advanced Example
Here’s a comprehensive example using advanced techniques:
import 'package:flutter/material.dart' ;
import 'package:flutter_test/flutter_test.dart' ;
import 'package:patrol_finders/patrol_finders.dart' ;
void main () {
patrolWidgetTest (
'advanced test with multiple techniques' ,
config : PatrolTesterConfig (
settlePolicy : SettlePolicy .trySettle,
visibleTimeout : Duration (seconds : 20 ),
),
($) async {
await $. pumpWidgetAndSettle ( const ComplexApp ());
// Scroll in specific ListView
await $( 'Item 99' )
. scrollTo (view : $(#secondList).$( Scrollable ))
. tap ();
// Find enabled button by property
await $( ElevatedButton )
. which < ElevatedButton >((btn) => btn.enabled)
. which < ElevatedButton >(
(btn) => btn.style ? .backgroundColor ? . resolve ({}) == Colors .blue,
)
. tap ();
// Complex chaining with containing
await $( Card )
. containing ($( 'Premium' ))
.$(#upgradeButton)
. tap ();
// Wait with custom timeout
await $( 'Success!' )
. waitUntilVisible (timeout : Duration (seconds : 30 ));
// Check visibility at specific alignment
expect (
$(#partialWidget). isVisibleAt (alignment : Alignment .topLeft),
true ,
);
// Tap with custom settle policy
await $( 'Confirm' ). tap (
settlePolicy : SettlePolicy .noSettle,
alignment : Alignment .bottomRight,
);
},
);
}
Best Practices
Use trySettle Set SettlePolicy.trySettle as default to handle apps with continuous animations
Specify Scrollables Always specify view when multiple Scrollables are present
Type your which() Always provide the type parameter to which<T>() for type safety
Check visibility Use visible instead of exists when you care about user visibility
Next Steps
API Reference Explore the complete API documentation
Back to Overview Review the fundamentals