Integration Testing Flutter Apps with Patrol

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: flutter

Install the Patrol CLI:

dart pub global activate patrol_cli

Then 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.dart

Writing 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();
  },
);
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 device

Running on Devices and Emulators

List connected devices:

flutter devices

Run on a specific device:

patrol test -d "iPhone 15 Pro"
patrol test -d emulator-5554

Run 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.dart

GitHub 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_LOCATION

This 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 = 2

Set 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.

Read more

Start now free