Skip to main content

Patrol for End-to-End Flutter Testing: Interacting with Native Permission Dialogs and OS Features

Published: · 12 min read
Sandra Rosa Antony
Software Engineer, Appxiom

End-to-end tests often fall apart the moment your app requests a system permission. The standard integration_test package can only see your Flutter widget tree - it can’t tap “Allow” on Android’s permission controller or “Allow While Using App” on iOS. That’s exactly where Patrol shines. With Patrol, you can write true end-to-end tests that drive both Flutter widgets and native UI, so you can reliably automate permission requests, notifications prompts, backgrounding, and other OS features.

This guide is a practical, production-focused walkthrough of patrol flutter integration testing. We’ll build a minimal permission flow, drive the native dialogs on Android and iOS, run tests locally via Patrol CLI, and wire it all into GitHub Actions CI. Along the way we’ll cover real-world pitfalls, stability tactics, and cross-platform differences.

What you’ll build

  • A minimal Flutter screen that requests location permission using permission_handler
  • A Patrol end-to-end test that: ◦ Launches the app ◦ Taps the request button in Flutter ◦ Interacts with the native permission dialog on Android and iOS ◦ Verifies the in-app result
  • Local runs using Patrol CLI on Android emulators and iOS simulators
  • CI pipeline to integrate Patrol in GitHub Actions (Android and iOS)

Prerequisites

  • Flutter 3.22+ (stable) and Dart 3.x
  • Xcode 15+ for iOS simulator runs (macOS only)
  • Android SDK 34+, an Android emulator or device
  • Cocoapods for iOS builds (if on macOS)
  • A recent Patrol package and Patrol CLI

Tip: When upgrading Flutter/Dart, also update patrol and permission_handler to their latest versions for best compatibility.

Why Patrol?

  • Interact with native UI: permission dialogs, settings panes, notifications, app switcher, etc.
  • Drive Flutter widgets and native elements from a single Dart test API.
  • Works on real devices, emulators, and simulators.
  • Fits into your existing integration_test workflow, but extends it to true end-to-end coverage.

Project setup

1. Add dependencies

pubspec.yaml (dev deps for tests + permission handler for demo):

name: patrol_permission_demo
description: Demo of Patrol end-to-end testing with native permission dialogs
publish_to: "none"

environment:
sdk: ">=3.3.0 <4.0.0"

dependencies:
flutter:
sdk: flutter
permission_handler: ^11.3.1

dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
patrol: ^3.0.0 # use latest stable

flutter:
uses-material-design: true

Then:

flutter pub get

2. iOS and Android permission configuration

  • iOS: add a usage description to Info.plist so iOS can show the system dialog.

ios/Runner/Info.plist:

<key>NSLocationWhenInUseUsageDescription</key>
<string>We use your location to show nearby content.</string>
  • Android: declare the permission in AndroidManifest.

android/app/src/main/AndroidManifest.xml (inside <manifest>):

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

Note:

  • On Android 12+ the system may also consider coarse location. Keep UX consistent in your app code if you require fine accuracy.
  • Ensure your app actually triggers a runtime permission request (done via permission_handler in our demo).

3. Install Patrol CLI

dart pub global activate patrol_cli
patrol doctor

Make sure $HOME/.pub-cache/bin is on your PATH so the patrol command is available.

Minimal app under test

We’ll build a tiny screen that asks for location permission and reports the result.

lib/main.dart:

import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
String _status = 'Permission not requested';

Future<void> _requestLocation() async {
final status = await Permission.locationWhenInUse.request();

setState(() {
if (status.isGranted) {
_status = 'Location granted';
} else if (status.isPermanentlyDenied) {
_status = 'Location permanently denied';
} else if (status.isDenied) {
_status = 'Location denied';
} else {
_status = 'Location status: $status';
}
});
}

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Patrol Permission Demo',
home: Scaffold(
appBar: AppBar(title: const Text('Patrol Permission Demo')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_status, key: const Key('permission_status')),
const SizedBox(height: 16),
ElevatedButton(
key: const Key('request_location'),
onPressed: _requestLocation,
child: const Text('Request location'),
),
],
),
),
),
);
}
}

Writing the Patrol end-to-end test

We’ll write a test that:

  1. Launches the app
  2. Taps the button in Flutter
  3. Accepts the native permission dialog on each platform
  4. Verifies widget state updates to “Location granted”

Create integration_test/permission_flow_test.dart:

import 'dart:io' show Platform;

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';

import 'package:patrol_permission_demo/main.dart' as app;

void main() {
patrolTest(
'requests location permission and accepts the dialog',
($) async {
// Start the app like a user would
app.main();
await $.pumpAndSettle();

// Trigger the permission request
await $.tap(find.byKey(const Key('request_location')));

// Interact with native permission dialogs
if (Platform.isAndroid) {
// Depending on Android version, text can differ:
// - "While using the app" / "Allow" (API <= 30)
// - "While using the app" or "Allow only while using the app" (API 31+)
// We'll use a broad "Allow" contains to keep it resilient.
await $.native.tap(Selector(textContains: 'Allow'));

// Some OEMs or versions show a two-step dialog:
// If another "Allow" or "While using" appears, try tapping it again.
// This extra tap is safe if nothing is visible.
await $.native.tap(Selector(textContains: 'Allow'), optional: true);
} else if (Platform.isIOS) {
// iOS commonly displays "Allow While Using App" and "Don’t Allow".
// Use labelContains for accessibility labels and variations across versions.
await $.native.tap(Selector(labelContains: 'Allow While Using'));
// On some iOS versions or languages, it might just be "Allow"
await $.native.tap(Selector(label: 'Allow'), optional: true);
}

// Wait for the app to react and re-render
await $.pumpAndSettle();

// Validate the in-app state after permission is granted
expect(find.text('Location granted'), findsOneWidget);
},
);
}

Notes:

  • patrolTest gives you $, a PatrolTester instance that can drive both Flutter and native UI.
  • $.tap(Finder) interacts with Flutter widgets.
  • $.native.tap(Selector(...)) interacts with native UI elements.
  • We use textContains/labelContains to guard against minor UI text variations across OS versions. You can also localize these strings if your CI runs in non-English locales.
  • The optional: true parameter allows the tap to be skipped if that selector is not found, improving resilience.

Running locally with Patrol CLI

  • Android (emulator or device connected):
# List Android devices to get an ID
adb devices

# Run the end-to-end test against a specific device
patrol test -t integration_test/permission_flow_test.dart -d emulator-5554
  • iOS (simulator; macOS only):
# List iOS simulators
xcrun simctl list devices

# Run on a specific simulator
patrol test -t integration_test/permission_flow_test.dart -d "iPhone 15"

If you have multiple Flutter flavors, build configs, or targets, Patrol supports passing build options (e.g., --flavor, --dart-define) similar to standard Flutter build/test commands. Run patrol --help for details.

Production tips for stable end-to-end testing

  • Centralize native selectors: Create “robots” or “page objects” for permission flows so text/label changes are updated in one place.
  • Prefer contains/matches selectors: OEM skins and OS updates tweak exact button labels. Use textContains, labelContains, or regex matches when appropriate.
  • Handle both first-run and subsequent runs: If permission was already granted, the dialog won’t appear - make your test robust by verifying the outcome rather than assuming the dialog always shows.
  • Pin emulators/simulators in CI: Use consistent device profiles and API levels to reduce flakiness caused by UI differences.
  • Test denial flows: CI often needs both “Allow” and “Don’t Allow” scenarios for analytics, error handling, and fallback UX.
  • Reset state when necessary: On CI, consider wiping the simulator/device between runs so the permission prompt always appears, or explicitly revoke the permission via platform tools.

Integrate Patrol in GitHub Actions

Below is a minimal setup for a two-job matrix: Android on Ubuntu and iOS on macOS. It installs Flutter, activates the Patrol CLI, boots devices, and runs the same Patrol end-to-end test.

.github/workflows/patrol-e2e.yml:

name: Patrol E2E

on:
push:
branches: [ main ]
pull_request:

jobs:
android:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- name: Set up Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"

- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true

- name: Flutter pub get
run: flutter pub get

- name: Activate Patrol CLI
run: dart pub global activate patrol_cli

- name: Run Patrol tests on Android emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 34
target: google_apis
arch: x86_64
profile: pixel_6
script: |
export PATH="$PATH:$HOME/.pub-cache/bin"
flutter pub get
patrol doctor
# Optional: clean/wipe to ensure permission dialog is shown every run
adb uninstall com.example.patrol_permission_demo || true
patrol test -t integration_test/permission_flow_test.dart

ios:
runs-on: macos-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4

- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true

- name: Install CocoaPods
run: |
sudo gem install cocoapods --no-document
pod repo update

- name: Flutter pub get
run: flutter pub get

- name: Activate Patrol CLI
run: dart pub global activate patrol_cli

- name: Boot iOS Simulator and run Patrol
env:
PATH: ${{ env.PATH }}:$HOME/.pub-cache/bin
run: |
SIM="iPhone 15"
xcrun simctl boot "$SIM" || true
xcrun simctl bootstatus "$SIM" -b
patrol doctor
# Optional: uninstall to re-trigger permission prompt each run
xcrun simctl uninstall "$SIM" com.example.patrolPermissionDemo || true
patrol test -t integration_test/permission_flow_test.dart -d "$SIM"

Notes:

  • Use consistent device profiles to avoid UI discrepancies.
  • Uninstalling the app between runs ensures fresh permission prompts in CI.
  • If you localize your app or the runner’s locale is not English, adapt selectors accordingly.

This satisfies the “integrate patrol in github actions” search intent with a practical, copy-pasteable workflow.

Extending to other OS prompts

The same pattern applies to:

  • Notifications: tap “Allow” vs “Don’t Allow” on iOS; handle Android 13+ notification permission dialog.
  • Photos/camera/microphone: buttons usually vary slightly by version; use textContains/labelContains.
  • Backgrounding and returning: after granting permission, you can continue your flow (e.g., open camera, capture media) and assert postconditions.

Keep OS and OEM variance in mind - prefer flexible selectors over exact text when possible, and add optional taps for multi-step prompts.

Common pitfalls and troubleshooting

  • The system dialog never shows:
    • Ensure Info.plist/AndroidManifest entries are correct.
    • Make sure your code calls request() at runtime (not just checks status).
    • If permission was already granted, the OS will not prompt again; uninstall the app or revoke permission before the test.
  • Flaky or slow CI:
    • Increase timeouts and add await $.pumpAndSettle() after significant UI transitions.
    • Use stable emulator/simulator versions and pin API levels.
  • Text doesn’t match across OS versions or locales:
    • Prefer textContains/labelContains, regex matches, or separate selectors by platform/OS version.
  • Build/system errors on iOS:
    • Ensure Cocoapods is installed and pod install runs if needed.
    • Validate Xcode Command Line Tools path: sudo xcode-select -switch /Applications/Xcode.app/Contents/Developer.
  • Android permission controller differences:
    • OEMs sometimes customize wording. Keep selectors loose (contains/matches) and consider adding a second optional tap.

Architecture and maintainability tips

  • Use the Robot pattern. Example:
class PermissionRobot {
final PatrolTester $;
PermissionRobot(this.$);

Future<void> allowLocationDialog() async {
if (Platform.isAndroid) {
await $.native.tap(Selector(textContains: 'Allow'));
await $.native.tap(Selector(textContains: 'Allow'), optional: true);
} else if (Platform.isIOS) {
await $.native.tap(Selector(labelContains: 'Allow While Using'));
await $.native.tap(Selector(label: 'Allow'), optional: true);
}
}

Future<void> denyLocationDialog() async {
if (Platform.isAndroid) {
await $.native.tap(Selector(textContains: 'Don’t allow'), optional: true);
await $.native.tap(Selector(textContains: 'Deny'), optional: true);
} else if (Platform.isIOS) {
await $.native.tap(Selector(labelContains: 'Don’t Allow'));
}
}
}
  • Keep OS logic centralized. If a new OS version changes wording, you only update one place.
  • Combine with your app’s architecture (Clean Architecture/BLoC/Riverpod) to inject fakes and run richer end-to-end scenarios with server mocks or local fixtures.

What about other test types?

  • Unit tests: Validate pure Dart logic and view models.
  • Widget tests: Validate widget trees without the OS.
  • Integration/end-to-end tests with Patrol: Validate real device flows including native UI and system features.

Use all three tiers for defense-in-depth quality.

Beyond CI: Monitoring Real-World Permission Drop-Offs with Appxiom

Automated tests in Patrol prove that your happy and unhappy paths work under ideal, simulated conditions. But in production, native OS permissions are one of the biggest conversion drop-off points in mobile apps.

Real users behave unpredictably: they dismiss system dialogs, hit OEM-specific battery/permission quirks (e.g., Xiaomi MIUI or Samsung OneUI), revoke permissions mid-session in system settings, or encounter silent native bridge failures that never register as fatal crashes.

To close the loop between pre-release automation and live user experience, pair Patrol with Appxiom Real User Monitoring (RUM)

Conclusion

Patrol elevates end to end testing flutter apps from widget-only checks to real device automation. With patrol flutter integration testing you can reliably handle flutter testing native permission dialogs, notifications prompts, and OS interactions that used to be out of reach. The Patrol CLI makes local runs simple and enables a clean patrol cli tutorial android ios workflow you can lift straight into CI. In GitHub Actions, you can integrate patrol in github actions to run on Android emulators and iOS simulators with predictable results.

Key takeaways:

  • Use Patrol when your tests need to interact with native UI or OS features.
  • Keep native selectors resilient using contains/matches and optional taps.
  • Reset app state in CI to consistently trigger first-run dialogs.
  • Centralize platform differences using Robots for maintainability.

Next steps:

  • Add denial and “Ask Next Time” flows.
  • Extend tests to notifications and camera/photo permissions.
  • Parallelize device matrices (API levels, iOS versions) to increase confidence before release.

With Patrol in place, your test suite will cover the last mile - where your app meets the OS.

Bridge the Gap Between Test Automation and Production Health

Patrol protects your releases in CI, but what happens when real users hit your native permissions in the wild? Don't let silent onboarding bottlenecks and permission friction destroy your funnel conversion.

Explore Appxiom plans and pricing to start tracking Goal Friction Impact, or check out our developer documentation to integrate our lightweight SDK in under 5 minutes. Protect your app's performance with a 30-day free trial.