Integration Testing with Patrol

FlutterDart
Share on LinkedIn

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

  1. Keys over text#loginButton not 'Log in' (localization breaks text finders).
  2. Avoid arbitrary delays — use pumpAndSettle and Patrol wait helpers.
  3. Reset app state — clear storage in setUp or launch fresh app per test.
  4. Seed test data — mock API or fixture backend, not production.
  5. 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 →