E2E Testing Flutter Apps with Patrol: Native Interactions and Permission Dialogs

E2E Testing Flutter Apps with Patrol: Native Interactions and Permission Dialogs

Patrol is a Flutter testing framework built on top of integration_test that lets you interact with native UI elements — permission dialogs, notifications, the status bar, deep links — without platform-specific ADB/xcrun hacks. This guide covers setup, writing PatrolTester tests, handling permissions, testing notifications, and running Patrol in CI.

Key Takeaways

  • Patrol wraps integration_test and adds native UI interaction via PatrolTester
  • Use $.native.grantPermissionWhenInUse() to handle system permission dialogs
  • patrolTest replaces testWidgets for Patrol-enhanced tests
  • Patrol's custom finders ($(MyWidget)) are more ergonomic than Flutter's built-in finders
  • The patrol CLI manages test execution, device selection, and CI integration

Flutter's integration_test package is excellent for testing your Flutter widget tree — but it hits a wall the moment a native system dialog appears. Permission requests, push notification prompts, biometric authentication dialogs, and share sheets are all rendered by the OS, completely outside Flutter's widget tree. Your test cannot see them, cannot tap them, and will hang indefinitely waiting for the user to dismiss them.

Patrol solves this. Built by LeanCode, Patrol wraps integration_test and adds a native automation layer that can interact with any element on the screen — Flutter widgets and native OS UI alike.

Why Patrol Exists

The problem is architectural. Flutter integration tests run inside the app process and can only interact with Flutter's rendering layer. System dialogs live in a separate OS process. Before Patrol, the workaround was:

# Grant permission via ADB before running the test (Android)
adb shell pm grant com.example.app android.permission.CAMERA

# Or grant via xcrun (iOS)
xcrun simctl privacy booted grant camera com.example.app

This works but is brittle — different devices, OS versions, and CI environments handle it differently. It does not test the actual user experience of granting permissions, and it cannot handle dialogs that appear mid-test during a real user flow.

Patrol uses XCTest (iOS) and UIAutomator (Android) under the hood to control the native layer, giving your tests the same power as a native automation framework while keeping the familiar Flutter test API.

Installation

Add to pubspec.yaml:

dev_dependencies:
  patrol: ^3.8.0
  integration_test:
    sdk: flutter

Install the Patrol CLI:

dart pub global activate patrol_cli

Initialize Patrol in your project:

patrol init

This creates:

  • integration_test/ — where your Patrol tests live
  • android/app/src/androidTest/ — Android native runner (auto-generated)
  • ios/RunnerUITests/ — iOS native runner (auto-generated)

Your First Patrol Test

Patrol tests use patrolTest instead of testWidgets and receive a PatrolTester ($) instead of WidgetTester:

import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';
import 'package:my_app/main.dart' as app;

void main() {
  patrolTest(
    'counter increments when button is tapped',
    ($) async {
      app.main();
      await $.pumpAndSettle();

      // PatrolTester's $ finder is more ergonomic
      expect($(#counter_text).text, equals('0'));

      await $(FloatingActionButton).tap();
      await $.pumpAndSettle();

      expect($(#counter_text).text, equals('1'));
    },
  );
}

Run with the Patrol CLI:

# Run on connected device
patrol test

# Run on specific device
patrol test --device emulator-5554

# Run a specific file
patrol test integration_test/counter_test.dart

The PatrolFinder: Ergonomic Widget Location

Patrol's $ finder is a drop-in enhancement of Flutter's find:

// By type
$(ElevatedButton)

// By text
$('Submit')

// By key (using Symbol)
$(#submit_button)  // equivalent to find.byKey(const Key('submit_button'))

// By ValueKey
$(const ValueKey('my-widget'))

// By semantic label
$(Semantics).containing('Close')

// Chain finders — find within a parent
$(Card).$(Text)        // Text inside a Card
$(ListTile).$('Alice') // Text 'Alice' inside a ListTile

Interactions work the same as WidgetTester but on PatrolFinder:

await $(#email_field).enterText('user@example.com');
await $('Login').tap();
await $.pumpAndSettle();

// Scroll until visible
await $.scrollUntilVisible(
  finder: $('Item 50'),
  view: $(ListView),
);

Handling Permission Dialogs

This is where Patrol genuinely shines. The $.native namespace provides access to native OS interactions:

Camera Permission

patrolTest('camera opens after permission granted', ($) async {
  app.main();
  await $.pumpAndSettle();

  // Navigate to feature that requires camera
  await $('Take Photo').tap();
  await $.pumpAndSettle();

  // Handle the native permission dialog
  await $.native.grantPermissionWhenInUse();
  await $.pumpAndSettle();

  // Camera view is now visible in the Flutter layer
  expect($(#camera_preview), findsOneWidget);
});

Location Permission

patrolTest('map shows user location after permission', ($) async {
  app.main();
  await $.pumpAndSettle();

  await $('Open Map').tap();
  await $.pumpAndSettle();

  // Grant location permission
  await $.native.grantPermissionWhenInUse();
  // On iOS, a second prompt sometimes appears for precise location
  if (await $.native.isPermissionDialogVisible()) {
    await $.native.tapOnPermissionDialogButton(
      PermissionDialogButton.allow,
    );
  }

  await $.pumpAndSettle();

  expect($(#user_location_marker), findsOneWidget);
});

Denying Permissions

patrolTest('app shows fallback UI when camera denied', ($) async {
  app.main();
  await $.pumpAndSettle();

  await $('Take Photo').tap();
  await $.pumpAndSettle();

  // Deny the permission
  await $.native.denyPermission();
  await $.pumpAndSettle();

  // Verify fallback UI is shown
  expect($('Camera permission required'), findsOneWidget);
  expect($('Open Settings'), findsOneWidget);
});

Notification Permission (iOS 14+)

patrolTest('push notifications enabled after permission grant', ($) async {
  app.main();
  await $.pumpAndSettle();

  // Trigger notification permission request
  await $('Enable Notifications').tap();
  await $.pumpAndSettle();

  await $.native.grantPermissionWhenInUse();
  await $.pumpAndSettle();

  expect($('Notifications enabled'), findsOneWidget);
});

Testing Push Notifications

Patrol can interact with the notification shade — something completely impossible with plain integration_test:

patrolTest('tapping notification deep links to correct screen', ($) async {
  app.main();
  await $.pumpAndSettle();

  // Open notification shade
  await $.native.openNotifications();

  // Tap the notification
  await $.native.tapOnNotificationByIndex(0);
  await $.pumpAndSettle();

  // App opened to the correct screen
  expect($(OrderDetailScreen), findsOneWidget);
  expect($('#order-12345'), findsOneWidget);
});

Or send a test notification and verify the in-app handling:

patrolTest('receives and displays push notification', ($) async {
  app.main();
  await $.pumpAndSettle();

  // Simulate a push notification via Firebase test tool or test backend
  await TestNotificationSender.send(
    title: 'New Message',
    body: 'Alice: Hey there!',
    data: {'screen': 'chat', 'userId': 'alice-123'},
  );

  // Wait for notification banner to appear (iOS foreground)
  await $.native.waitUntilVisible(Selector(text: 'New Message'));
  await $.native.tapOnNotificationByText('New Message');
  await $.pumpAndSettle();

  // Verify navigation
  expect($(ChatScreen), findsOneWidget);
  expect($('Alice'), findsOneWidget);
});

Interacting with Native UI Elements

Beyond permission dialogs, Patrol can interact with any native UI:

System Alert Dialogs

// Handle iOS "This app wants to use Sign in with Apple"
await $.native.tapOnAlertButton(alertButtonText: 'Continue');

// Handle Android system dialogs
await $.native.tapOnNativeButtonByText('OK');
patrolTest('deep link opens product detail', ($) async {
  app.main();
  await $.pumpAndSettle();

  // Simulate opening a deep link
  await $.native.openUrl('myapp://products/widget-pro-2026');
  await $.pumpAndSettle();

  expect($(ProductDetailScreen), findsOneWidget);
  expect($('Widget Pro 2026'), findsOneWidget);
});

App Lifecycle

patrolTest('resumes from background correctly', ($) async {
  app.main();
  await $.pumpAndSettle();

  // Login and navigate to home
  // ...

  // Send app to background
  await $.native.pressHome();
  await Future.delayed(const Duration(seconds: 2));

  // Bring back to foreground
  await $.native.openApp();
  await $.pumpAndSettle();

  // State is preserved
  expect($(HomeScreen), findsOneWidget);
  expect($('Welcome back, Alice'), findsOneWidget);
});

Keyboard Interactions

patrolTest('form submits on keyboard done action', ($) async {
  app.main();
  await $.pumpAndSettle();

  await $(#email_field).enterText('test@example.com');

  // Press the Done/Go button on the native keyboard
  await $.native.pressEnter(); // or pressGo(), pressSearch()

  await $.pumpAndSettle();

  expect($('Logged in'), findsOneWidget);
});

Test Setup and Teardown in Patrol

Patrol tests support the same setUp/tearDown lifecycle as regular tests:

void main() {
  group('Profile flow', () {
    setUpAll(($) async {
      // Run once before all tests in this group
    });

    setUp(($) async {
      // Clear app data before each test
      final prefs = await SharedPreferences.getInstance();
      await prefs.clear();
    });

    tearDown(($) async {
      // Dismiss any lingering dialogs
      if (await $.native.isPermissionDialogVisible()) {
        await $.native.denyPermission();
      }
    });

    patrolTest('updates profile name', ($) async {
      app.main();
      await $.pumpAndSettle();
      // ...
    });
  });
}

Patrol Configuration

Configure Patrol's behavior in patrol.yaml at the project root:

app_name: MyApp
app_id: com.example.myapp

android:
  package_name: com.example.myapp

ios:
  bundle_id: com.example.myapp

test_runner_host: localhost
test_runner_port: 8081

And in pubspec.yaml for the patrol dependency:

# Fine-tune Patrol behavior
patrol:
  app_name: MyApp

Running Patrol in CI

GitHub Actions (Android)

name: Patrol E2E Tests

on: [push, pull_request]

jobs:
  patrol-android:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - 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
        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 Patrol tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 33
          arch: x86_64
          profile: Nexus 6
          script: patrol test

      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: patrol-results-android
          path: build/app/outputs/androidTest-results/

GitHub Actions (iOS)

  patrol-ios:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - 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"

      - name: Run Patrol tests
        run: patrol test --platform ios

Patrol vs Plain integration_test: When to Use Which

Scenario Plain integration_test Patrol
Testing Flutter-only UI flows Yes Yes (overkill)
Permission dialogs No Yes
Push notifications No Yes
Deep links Partial Yes
Native share sheet No Yes
Background/foreground transitions No Yes
Biometric authentication No Yes
System settings navigation No Yes

Start with plain integration_test for pure Flutter flows. Reach for Patrol when you need to cross into native territory.

Flakiness: The E2E Test Enemy

E2E tests are inherently flakier than unit or widget tests. Network latency, animation timing, and device performance all vary. Patrol-specific tips:

1. Use .waitUntilVisible() instead of pumpAndSettle for long operations

// Waits up to the timeout for the widget to appear
await $(#order_confirmation).waitUntilVisible(
  timeout: const Duration(seconds: 15),
);

2. Always handle dialogs in tearDown

Dismissed dialogs from previous tests can appear at the start of the next test. Check and dismiss in tearDown.

3. Avoid Future.delayed

Use Patrol's wait utilities instead of arbitrary delays. Future.delayed(const Duration(seconds: 3)) is a test smell.

4. Run tests individually before running the full suite

A test that passes alone but fails in sequence indicates shared state leaking between tests.

Scaling E2E Testing with Cloud Infrastructure

Running Patrol tests on a CI emulator handles development-time checks. Real-world coverage requires testing on actual devices — real Android hardware at various OS versions, physical iPhones and iPads. Cloud device farms (AWS Device Farm, Firebase Test Lab, BrowserStack) provide this, but they require setup, credentials, and ongoing maintenance.

HelpMeTest provides cloud-hosted E2E test execution for Flutter apps, including Patrol tests, across a matrix of real Android and iOS devices. No device farm setup, no credentials to manage, no CI infrastructure to maintain. With usage-based pricing, you get AI-powered test analysis, flakiness detection, and device coverage recommendations based on your user analytics.

Write the tests. Let HelpMeTest handle the infrastructure.

Read more

Start now free