Integration Testing Flutter Apps with Patrol
Flutter's built-in integration test framework (package:integration_test) gets you far, but it has a critical blind spot: anything outside the Flutter layer. Native permission dialogs, system notifications, the device keyboard, deep links opened from the browser — these are all invisible to integration_test. Patrol fills that gap.
Developed by LeanCode, Patrol wraps Flutter's integration test infrastructure with a NativeAutomator API that can interact with native UI on both Android and iOS. It also ships patrol_finders, a more ergonomic finder API that replaces the verbose tester.pump/tester.pumpAndSettle calls you are used to. This post covers setup, the key APIs, running tests on real devices, and plugging Patrol into CI.
Why Patrol over integration_test?
Standard integration_test limitations you hit in real projects:
- Cannot tap "Allow" on a location/camera/notification permission prompt.
- Cannot scroll the native date picker on iOS.
- Cannot verify that a push notification appeared in the notification shade.
- Cannot handle the "App wants to open a link in Safari" system alert.
Patrol solves all of these. It pairs a Flutter-side Dart test runner with a native automation driver (XCUITest on iOS, UIAutomator2 on Android) that can reach outside your app.
Setup
Add Patrol to your pubspec.yaml:
dev_dependencies:
patrol: ^3.0.0
integration_test:
sdk: flutterInstall the Patrol CLI:
dart pub global activate patrol_cliThen run the one-time setup for each platform:
patrol doctor # Verify setup
patrol build android # Generate native glue code
patrol build ios # Generate XCUITest target (XCode required)For iOS, Patrol adds a RunnerUITests XCUITest target to your Xcode project. You need to add your test scheme to the project manually once (Patrol's docs walk through this with screenshots). Android requires no Xcode changes.
Your test file structure:
integration_test/
app_test.dart
login_test.dart
notification_test.dartWriting Your First Patrol Test
Patrol tests use patrolTest instead of testWidgets. The callback receives a PatrolIntegrationTester ($) that extends WidgetTester with Patrol's extras.
import 'package:patrol/patrol.dart';
import 'package:my_app/main.dart' as app;
void main() {
patrolTest(
'User can log in and reach the home screen',
($) async {
app.main();
await $.pumpAndSettle();
await $(#emailField).enterText('user@example.com');
await $(#passwordField).enterText('hunter2');
await $('Log in').tap();
await $.pumpAndSettle();
expect($('Welcome, user!'), findsOneWidget);
},
);
}Notice the $ shorthand — $(#emailField) is equivalent to find.byKey(const Key('emailField')). Patrol uses Dart's Symbol type to look up widget keys cleanly.
patrol_finders: A Better Finder API
patrol_finders can also be used in standard widget tests (without the native automation layer). It eliminates the need to manually call pump after every action.
import 'package:patrol_finders/patrol_finders.dart';
void main() {
patrolWidgetTest('Counter increments', ($) async {
await $.pumpWidget(const MaterialApp(home: CounterPage()));
await $('Increment').tap(); // Automatically pumps after tap
await $('Increment').tap();
expect($('2'), findsOneWidget);
});
}Key improvements over raw WidgetTester:
- Auto-pumps after taps, enters, scrolls.
waitUntilVisible— polls until a widget appears (useful for async data loading).- Chaining:
$(ListView).$(Text)scopes finders to descendants.
// Wait for an async list to load, then interact
await $(CircularProgressIndicator).waitUntilGone(timeout: const Duration(seconds: 5));
await $('Apple').tap();
// Scoped finder — find a Delete button inside the second card
await $(Card).at(1).$(IconButton).tap();NativeAutomator: Reaching Outside Flutter
This is Patrol's killer feature. The NativeAutomator is available via $.native:
patrolTest(
'App requests and handles camera permission',
($) async {
app.main();
await $.pumpAndSettle();
await $('Open Camera').tap();
// Native permission dialog appears — Flutter can't see it
await $.native.grantPermissionWhenInUse(); // iOS: "Allow While Using App"
// or
await $.native.grantPermissionOnlyThisTime();
// or
await $.native.denyPermission();
await $.pumpAndSettle();
expect($('Camera is ready'), findsOneWidget);
},
);Interacting with Native UI Elements
// Tap a native button by text
await $.native.tap(NativeSelector(text: 'Continue'));
// Enter text in a native text field (e.g., Safari address bar)
await $.native.enterTextByIndex('https://example.com', index: 0);
// Scroll native views
await $.native.scroll(
selector: NativeSelector(resourceId: 'com.android.settings:id/list'),
dx: 0,
dy: 100,
);Notifications
patrolTest(
'App receives and handles push notification',
($) async {
app.main();
await $.pumpAndSettle();
// Grant notification permission (iOS 16+)
await $.native.grantPermissionWhenInUse();
// Trigger a notification from your app
await $('Send Test Notification').tap();
// Open the notification shade
await $.native.openNotifications();
// Tap the notification
await $.native.tap(NativeSelector(text: 'New message from Alice'));
// Verify deep link opened the right screen
await $.pumpAndSettle();
expect($('Alice'), findsOneWidget);
// Close notification shade if still open
await $.native.closeNotifications();
},
);Deep Links and URLs
patrolTest(
'Deep link opens correct product page',
($) async {
app.main();
await $.pumpAndSettle();
// Open a URL — may trigger system "Open in app?" dialog
await $.native.openUrl('myapp://products/42');
await $.pumpAndSettle();
expect($('Product #42'), findsOneWidget);
},
);Configuration with patrol.toml
Create patrol.toml at your project root for reusable configuration:
[patrol]
app_name = "My App"
app_id = "com.example.myapp" # Android package / iOS bundle ID
test_id = "com.example.myapp.RunnerUITests" # iOS test target
[android]
package_name = "com.example.myapp"
[ios]
bundle_id = "com.example.myapp"This lets you run tests without flags:
patrol test # Run on connected device
patrol test --target integration_test/login_test.dart
patrol test -d emulator-5554 # Specific deviceRunning on Devices and Emulators
List connected devices:
flutter devicesRun on a specific device:
patrol test -d "iPhone 15 Pro"
patrol test -d emulator-5554Run in parallel on multiple devices:
patrol test -d "Pixel 7" -d "iPhone 15"Patrol runs each test file as a separate instrumentation process on Android, and as a separate XCUITest run on iOS. This means you get real parallel execution when devices are available.
Structuring Tests for Maintainability
Group related native interactions into helper classes:
// test_helpers/permission_helper.dart
extension PermissionHelper on PatrolIntegrationTester {
Future<void> grantAllAppPermissions() async {
await native.grantPermissionWhenInUse(); // Location
await pumpAndSettle();
await native.grantPermissionWhenInUse(); // Camera
await pumpAndSettle();
}
}Use a shared app startup helper to avoid repeating app.main() + pumpAndSettle():
// test_helpers/app_helper.dart
Future<void> startApp(PatrolIntegrationTester $) async {
app.main();
await $.pumpAndSettle(timeout: const Duration(seconds: 10));
// Wait for splash screen to pass
await $(SplashScreen).waitUntilGone();
}CI Integration
GitHub Actions (Android)
name: Patrol Integration Tests
on: [push, pull_request]
jobs:
android-integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Java
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.22.0'
- name: Install Patrol CLI
run: dart pub global activate patrol_cli
- name: Enable KVM (Android emulator acceleration)
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Run Android emulator + tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
arch: x86_64
profile: Nexus 6
script: patrol test --target integration_test/app_test.dartGitHub Actions (iOS) — macOS runner required
ios-integration:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: '3.22.0'
- name: Install Patrol CLI
run: dart pub global activate patrol_cli
- name: Start iOS Simulator
run: |
xcrun simctl boot "iPhone 15 Pro"
- name: Run tests
run: |
patrol test \
--target integration_test/app_test.dart \
-d "iPhone 15 Pro"Tips for Reliable CI Runs
Grant permissions pre-emptively on Android emulators:
# Before running tests
adb shell pm grant com.example.myapp android.permission.CAMERA
adb shell pm grant com.example.myapp android.permission.ACCESS_FINE_LOCATIONThis avoids permission dialogs entirely in CI, making tests faster and less flaky. Reserve $.native.grantPermission* for testing the actual permission flow in dedicated tests.
Retry flaky tests:
# patrol.toml
[patrol]
max_retries = 2Set timeouts at test level:
patrolTest(
'Slow network test',
timeout: const Timeout(Duration(minutes: 3)),
($) async {
// ...
},
);Comparing Patrol to integration_test
| Capability | integration_test | Patrol |
|---|---|---|
| Widget interactions | Yes | Yes (better API) |
| Native permission dialogs | No | Yes |
| System notifications | No | Yes |
| Deep links / URLs | No | Yes |
| Native scrolling | No | Yes |
| Auto-pump after actions | No | Yes |
| CI Android support | Yes | Yes |
| CI iOS support | Yes | Yes (macOS runner) |
| Parallel device runs | No | Yes |
When Not to Use Patrol
Patrol adds native dependencies and increases test run time significantly compared to widget tests. Reserve it for flows that genuinely require native layer interaction:
- Permission grants/denials
- Push notification handling
- Deep link activation
- Biometric authentication prompts
- App-to-app interactions (share sheet, etc.)
Everything else should stay in widget tests, which are 10-100x faster and run on any machine without a device.
Conclusion
Patrol bridges the gap between Flutter widget tests and full end-to-end test coverage. Its NativeAutomator API handles the permission dialogs, notifications, and native UI elements that integration_test cannot reach, while patrol_finders makes the Dart-side interaction code cleaner. The CI setup requires more infrastructure than unit tests, but for apps with complex permission flows or notification handling, Patrol delivers coverage that was previously only achievable with Appium or Detox — without leaving the Dart ecosystem.