Integration Testing with Patrol
Standard integration_test couldn't tap the iOS location permission dialog. Our test hung forever waiting for a screen that required native "Allow" first. Patrol fixed it—$.native.tap(Selector(text: 'Allow'))—and suddenly E2E tests covered the flows users actually experience. Integration testing in Flutter historically meant widget tree only; hybrid and permission-gated apps need native reach-through.
Setup
dev_dependencies:
patrol: ^3.11.0
integration_test:
sdk: flutter
Install CLI:
dart pub global activate patrol_cli
patrol init
Creates native configuration in android/ and ios/. Follow post-init instructions for Gradle and Podfile changes.
integration_test/login_test.dart:
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';
import 'package:my_app/main.dart' as app;
void main() {
patrolTest('user can log in', ($) async {
app.main();
await $.pumpAndSettle();
await $(#emailField).enterText('[email protected]');
await $(#passwordField).enterText('password123');
await $(#loginButton).tap();
await $.pumpAndSettle();
expect($(#homeScreen), findsOneWidget);
});
}
Run:
patrol test --target integration_test/login_test.dart
Patrol finders
Patrol extends flutter_test finders with fluent $ syntax:
await $(#myKey).tap();
await $('Sign in').tap();
await $(TextField).enterText('hello');
await $(Icons.add).tap();
// Waits and scrolls automatically
await $(#offScreenItem).scrollTo().tap();
$.pumpAndSettle() replaces manual tester.pumpAndSettle() with Patrol-aware timing.
Native automation
Handle permission dialogs:
patrolTest('location permission flow', ($) async {
app.main();
await $(#requestLocationButton).tap();
// Native iOS/Android dialog
if (await $.native.isPermissionDialogVisible()) {
await $.native.tap(Selector(text: 'While using the app'));
}
await $.pumpAndSettle();
expect($(#mapView), findsOneWidget);
});
Open system settings:
await $.native.openAppSettings();
await $.native.tap(Selector(text: 'Notifications'));
Selector API matches native UI by text, class name, or resource id—platform-specific docs in Patrol guides.
WebView and hybrid content
await $.native.waitUntilVisible(Selector(text: 'Checkout'));
await $.native.tap(Selector(text: 'Pay now'));
Patrol accesses WebView DOM on supported configurations—critical for payment and OAuth flows embedded in WebView.
Test organization
integration_test/
flows/
login_test.dart
checkout_test.dart
robots/
login_robot.dart
home_robot.dart
test_bundle.dart
Robot pattern:
class LoginRobot {
LoginRobot(this.$);
final PatrolIntegrationTester $;
Future<void> login(String email, String password) async {
await $(#emailField).enterText(email);
await $(#passwordField).enterText(password);
await $(#loginButton).tap();
await $.pumpAndSettle();
}
}
Keeps tests readable; robots encapsulate selector changes.
CI configuration
GitHub Actions excerpt:
- name: Run Patrol tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
script: |
patrol test --target integration_test/login_test.dart
iOS requires macOS runner with simulator boot. Capture screenshots on failure:
patrolTest('...', ($) async {
// ...
}, onFailure: ($, details) async {
await $.native.takeScreenshot('failure_${details.exception}');
});
Flake reduction
- Keys over text —
#loginButtonnot'Log in'(localization breaks text finders). - Avoid arbitrary delays — use
pumpAndSettleand Patrol wait helpers. - Reset app state — clear storage in
setUpor launch fresh app per test. - Seed test data — mock API or fixture backend, not production.
- Run on real devices periodically — emulators miss platform-specific bugs.
Patrol vs Maestro vs integration_test
| Tool | Native UI | Learning curve |
|---|---|---|
| integration_test | No | Low |
| Patrol | Yes | Medium |
| Maestro | Yes (YAML) | Low for QA |
Patrol fits teams already invested in Dart tests wanting native reach without separate YAML suite.
Test data isolation
Use flavor-specific backend or mock server in integration tests:
@Tags(['integration'])
patrolTest('checkout flow', ($) async {
await $.native.clearAppData(); // platform-specific reset
app.mainIntegration(); // entrypoint with mock API base URL
});
Separate main_integration.dart registering mock DI prevents test pollution of production endpoints.
Record Patrol test videos in CI artifacts on failure—native dialog timing issues reproduce intermittently; video captures whether Allow button appeared before timeout. Run subset of Patrol tests nightly full suite if PR pipeline time exceeds team patience threshold.
Production teams should treat this guidance as living documentation: revisit assumptions after major platform upgrades, measure outcomes with real metrics rather than checklist compliance, and pair written standards with automated checks in CI. The patterns here reflect what held up in shipped apps—not theoretical perfection. Adapt thresholds, timeouts, and tooling to your stack, but keep the underlying principles: explicit configuration, testable behavior, and failure modes users can understand.
When onboarding new engineers, walk through one end-to-end example in a debug build before asking them to extend the pattern. Most failures I have seen came from skipped platform setup steps—manifest entries, API keys, code generation, or permission prompts—not from misunderstanding the Dart layer. Keep a troubleshooting section in your team wiki linking official docs and the exact commands that worked for your last upgrade.
Schedule a quarterly review of this implementation against current SDK release notes—Flutter and cloud providers ship breaking changes on six-month cadences, and assumptions that held last year may need adjustment. Capture before-and-after metrics when you change configuration so regressions are obvious in retrospect rather than debated from memory.
Version-pin dependencies mentioned here in your pubspec.lock or infrastructure modules, and note the Flutter/Dart SDK constraint your team validated. Upgrading without re-running the verification steps in this article is the most common source of regressions. If something fails after an upgrade, compare release notes first, then your git history for the last known-good configuration.
Pair this setup with logging sufficient to diagnose field failures: request identifiers, cache keys, and user-visible error codes. Support teams need traceability from a screenshot to the underlying state without redeploying debug builds.
Run Patrol tests on Firebase Test Lab device matrix monthly — emulator-only CI misses OEM-specific permission dialogs.
Resources
Frequently asked questions
What is Patrol for Flutter testing?
Patrol is an integration testing framework extending flutter_test with native automation capabilities. It can tap OS permission dialogs, notification shade, WebView content, and native UI elements that standard integration_test cannot reach. Built by LeanCode, it runs on real devices and emulators with a custom test runner.
How is Patrol different from integration_test?
integration_test drives only Flutter widget tree via flutter_driver semantics. Patrol adds platform channels to interact with native Android and iOS UI—Allow/Deny buttons, settings screens, hybrid app content. Patrol also improves developer ergonomics with hot restart in tests and clearer failure messages.
Can Patrol run in CI?
Yes—Patrol provides CLI for headed and headless runs on Firebase Test Lab, GitHub Actions macOS/Linux runners, and Codemagic. Install patrol_cli, configure patrol test in CI with emulator boot wait, and upload artifacts on failure. Native automation requires platform-specific setup in android/ and ios/ folders generated by patrol init.
Hiring a senior Android / Flutter engineer?
I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.
Get in touch →