Skip to main content
Back to Blog
Tutorial
2026-09-28

Patrol Flutter: Native E2E Testing Guide

Patrol Flutter guide for QA engineers: setup, native automation, CLI flags, screenshots, CI, device farms, and when to use Patrol over Appium.

Patrol Flutter: Native E2E Testing Guide

Patrol Flutter testing is the strongest fit when your end-to-end scenario starts in Flutter but must cross into native device behavior: permissions, notifications, WebViews, system settings, backgrounding, dark mode, connectivity, and platform views. I verified Patrol against the official LeanCode docs, pub.dev, and the leancodepl/patrol repository. Patrol is active, open source, and maintained by LeanCode. The current pub.dev package I verified is patrol 4.10.0, with prerelease 4.11.0-dev.1. The current stable patrol_cli I verified is 4.8.0, with prerelease 4.9.0-dev.2.

That version detail matters because recent Patrol releases changed the default test directory to patrol_test, added web support through Playwright, added build-time test discovery, and in Patrol 4.10.0 added opt-in Android native failure screenshots and $.takeNativeScreenshot('tag'), requiring patrol_cli 4.8.0 or newer. If an older article tells you to put every Patrol test under integration_test or to call deprecated native APIs directly, treat it as dated.

Use Patrol when Flutter's official integration_test package cannot touch the surface you need. Use plain integration_test when the flow is entirely Flutter-rendered and you want the smallest official harness. Use Appium 2 mobile automation reference 2026 when you need black-box, cross-app, cross-language automation. Use XCUITest iOS UI testing tutorial 2026 when iOS-native fidelity and Apple's runner are the center of the job.

Current Status And Why Patrol Exists

Flutter's official docs now name two common packages for integration testing: integration_test, which is part of the Flutter SDK, and patrol, a third-party package that can additionally interact with native platform UI such as permission dialogs, notifications, and platform-view contents. Patrol's own docs describe the same gap: integration_test does a good job for basic Flutter integration tests, but it cannot interact with the OS your app is running on.

Patrol solves that by integrating Flutter tests with native Android and iOS test infrastructure. The patrol test command builds the app under test and the instrumentation app, installs them on the selected device, runs tests natively, and reports results in native format. Under the hood, the CLI calls Gradle on Android and xcodebuild on iOS.

Fact verifiedCurrent statusWhy QA teams should care
Package ownerLeanCode, open source repo leancodepl/patrolNot a one-off community snippet.
patrol latest4.10.0 on pub.devIncludes Android native failure screenshots and build-time discovery fixes.
patrol_cli latest4.8.0 on pub.devRequired for the newer screenshot path.
Default test directorypatrol_test since Patrol 4.0Older integration_test examples may not run by default.
Web supportVia Playwright through patrol test --device chromeBrowser artifacts land in test-results/.
Native layerEspresso and XCUITest style executionDevice farms can run Patrol as native tests.

The common misunderstanding is that Patrol replaces every Flutter test. It does not. Unit tests still belong in test/. Widget tests still belong in test/. Simple app flows can still use integration_test. Patrol earns its maintenance cost when the flow depends on native surfaces or when the team wants Flutter-authored tests that device farms can execute as native tests.

Install And Configure Patrol

The official install docs use flutter pub global activate patrol_cli, followed by patrol doctor. The CLI must be on your PATH. The docs also note that Patrol CLI invokes Flutter for some commands and can be pointed at a version manager using --flutter-command or the PATROL_FLUTTER_COMMAND environment variable. That is useful for FVM or puro teams.

flutter pub global activate patrol_cli
patrol doctor
flutter pub add patrol --dev

The minimum useful pubspec.yaml includes a patrol section with app identity. The official setup page shows app_name, Android package_name, iOS bundle_id, and macOS bundle_id. It also documents test_directory when you intentionally want a custom directory, and screenshot_on_failure: true for native Android failure screenshots.

dev_dependencies:
  flutter_test:
    sdk: flutter
  patrol: ^4.10.0

patrol:
  app_name: QA Skills Demo
  screenshot_on_failure: true
  android:
    package_name: sh.qaskills.demo
  ios:
    bundle_id: sh.qaskills.demo
  macos:
    bundle_id: sh.qaskills.demo.macos

If your repo still stores Patrol tests under integration_test/, either move them to patrol_test/ or set patrol.test_directory explicitly. Do not rely on a tribal-memory command that only one engineer runs locally. Put the directory choice in version control so AI coding agents and CI both see the same contract.

First Patrol Test With Native Intent

A Patrol test uses patrolTest and a PatrolIntegrationTester, conventionally named $. Patrol custom finders let you write $('Log in'), $(Key('loginButton')), or $(#loginButton). The docs also show that you can wrap normal Flutter finders, such as find.bySemanticsLabel('Edit profile'), inside Patrol finders.

This self-contained example keeps the app in the test file so the mechanics are runnable. A real app would import its app widget and test support setup.

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

void main() {
  patrolTest('signs in and verifies the dashboard state', ($) async {
    await $.pumpWidgetAndSettle(const DemoApp());

    expect($('Email').visible, equals(true));
    await $(#emailField).enterText('qa@example.com');
    await $(#passwordField).enterText('correct-horse');
    await $(#signInButton).tap();

    await $('Dashboard').waitUntilVisible();
    expect($('Signed in as qa@example.com').visible, equals(true));
  });
}

class DemoApp extends StatefulWidget {
  const DemoApp({super.key});

  @override
  State<DemoApp> createState() => _DemoAppState();
}

class _DemoAppState extends State<DemoApp> {
  final TextEditingController email = TextEditingController();
  bool signedIn = false;

  @override
  void dispose() {
    email.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: signedIn
            ? Center(
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: <Widget>[
                    const Text('Dashboard'),
                    Text('Signed in as ${email.text}'),
                  ],
                ),
              )
            : Padding(
                padding: const EdgeInsets.all(24),
                child: Column(
                  children: <Widget>[
                    const Text('Email'),
                    TextField(key: const ValueKey<String>('emailField'), controller: email),
                    const Text('Password'),
                    const TextField(key: ValueKey<String>('passwordField'), obscureText: true),
                    TextButton(
                      key: const ValueKey<String>('signInButton'),
                      onPressed: () => setState(() => signedIn = email.text.isNotEmpty),
                      child: const Text('Log in'),
                    ),
                  ],
                ),
              ),
      ),
    );
  }
}

Run it with Patrol, not flutter test. The official install docs are blunt on this point: Patrol CLI is necessary to run UI tests, and flutter test will not work for Patrol UI tests.

patrol test --target patrol_test/sign_in_test.dart

For development, patrol develop --target patrol_test/example_test.dart gives a hot restart loop. After the initial build, type r in the terminal to rerun the test logic without paying the full app build cost each time. That is where Patrol feels different from a traditional device-runner workflow.

Native Automation Patterns

Patrol's native automation lives under $.platform in current examples. The official native overview shows actions like enabling cellular, disabling Wi-Fi, enabling dark mode, selecting fine location, granting runtime permissions, opening notifications, and tapping notifications. These are not decorative APIs. They are the reason to choose Patrol over plain integration_test.

Native taskPatrol fitRisk to design around
Permission dialog acceptanceStrongDialog language and OS version can affect selector behavior.
Notification tapStrongNotification timing and app foreground state must be controlled.
WebView or OAuth loginStronger than integration_testReal identity providers add flake and account-lock risk.
Dark mode and device setting checksStrongReset settings after the test or isolate the device.
Offline behaviorUseful, platform-dependentiOS simulator and device farms may limit network toggling.
Camera QR scan on device farmWeakSome farms do not allow replacing the camera scene.

Here is a focused pattern for a permission-dependent location screen. The app is minimal, but the test shape is the important part: set the device condition, launch the widget, trigger the permission path, then assert a product-level outcome.

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

void main() {
  patrolTest('grants location and shows nearby stores', ($) async {
    await $.pumpWidgetAndSettle(const LocationApp());

    await $(#findStoresButton).tap();
    await $.platform.mobile.selectFineLocation();
    await $.platform.mobile.grantPermissionWhenInUse();

    await $('Nearby stores').waitUntilVisible();
    expect($('Store A').visible, equals(true));
  });
}

class LocationApp extends StatefulWidget {
  const LocationApp({super.key});

  @override
  State<LocationApp> createState() => _LocationAppState();
}

class _LocationAppState extends State<LocationApp> {
  bool loaded = false;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Column(
          children: <Widget>[
            TextButton(
              key: const ValueKey<String>('findStoresButton'),
              onPressed: () async {
                // Triggers the real OS permission dialog that the test answers.
                final status = await Permission.locationWhenInUse.request();
                setState(() => loaded = status.isGranted);
              },
              child: const Text('Find stores'),
            ),
            if (loaded) ...<Widget>[
              const Text('Nearby stores'),
              const Text('Store A'),
            ],
          ],
        ),
      ),
    );
  }
}

The app requests the permission through the permission_handler plugin, so the platform dialog really appears and the Patrol calls have something to answer. Add ACCESS_FINE_LOCATION to AndroidManifest.xml, add NSLocationWhenInUseUsageDescription to Info.plist, and enable the plugin's location permission macro in the iOS Podfile before running it. If the store list only appears after a grant, the final assertion proves the grant path worked rather than just that a button was tapped.

On a real project, the button would call the platform permission API before state changes. The test still needs to assert the business result, not merely that a permission button was tapped. If the user grants location and the app never loads stores, the native interaction succeeded but the product failed.

Finder Strategy: Concise, But Not Casual

Patrol finders are intentionally compact. $('Subscribe') finds text. $(#loginButton) maps to a key symbol. $(ListView).$(ListTile).$('Subscribe') scopes a repeated label through descendants. The docs also point out a difference that matters to QA: Flutter's normal matchers can check presence in the widget tree, while Patrol's visible getter focuses on user-visible widgets.

PatternUse it forAvoid when
$(#primaryCta).tap()Stable app-owned controlsThe key does not exist in production widgets.
$('Welcome').waitUntilVisible()Human-visible stateText changes frequently through localization experiments.
$(find.bySemanticsLabel('Edit profile')).tap()Accessibility-aligned selectorsSemantics are missing or misleading.
$(ListView).$(ListTile).$('Subscribe').tap()Repeated text in a scoped regionList ordering is nondeterministic.
which() property filteringCases where hierarchy is insufficientA simple key or semantics label would be clearer.

What people get wrong: they read Patrol's compact syntax as permission to write vague tests. Concision is not a substitute for contracts. Prefer keys on critical actions, semantics labels on icon-only controls, and product-visible text for final state. A test that taps $('OK') three times is easy to write and hard to debug.

CLI Flags QA Engineers Actually Use

The patrol test page says the command runs all files ending in _test.dart under patrol_test by default. You can run one file with --target, repeat --target, or pass comma-separated paths through --targets. The docs say there is no difference between --target and --targets.

patrol test
patrol test --target patrol_test/login_test.dart
patrol test \
  --target patrol_test/login_test.dart \
  --target patrol_test/notifications_test.dart
patrol test --targets patrol_test/login_test.dart,patrol_test/notifications_test.dart

Tags are useful when the same test tree contains platform-specific or expensive flows. Patrol supports --tags and --exclude-tags, including expressions such as (android && tablet) or android||ios.

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

void main() {
  patrolTest(
    'opens Android notification settings',
    tags: <String>['android', 'settings'],
    ($) async {
      await $.pumpWidgetAndSettle(const MaterialApp(home: Text('Settings demo')));
      await $.platform.mobile.openNotifications();
    },
  );
}
patrol test --tags android
patrol test --tags='(android && settings)'
patrol test --exclude-tags slow

Coverage exists but has constraints. Patrol docs say coverage collection is not supported on macOS, requires a debug build, and is incompatible with --profile and --release. The LCOV report is saved to /coverage/patrol_lcov.info, and --coverage-ignore accepts glob patterns.

patrol test --coverage
patrol test --coverage --coverage-ignore="**/*.g.dart"

Build metadata is also first-class. Use --build-name and --build-number when the test artifact must correspond to a release candidate.

patrol test --target patrol_test/login_test.dart --build-name=1.2.3 --build-number=123

Isolation, Screenshots, And Videos

Patrol's docs separate run isolation by platform. On Android, set clearPackageData to true in Gradle. On iOS Simulator, use the experimental --full-isolation flag. That flag may change, so pin the behavior in your team docs and verify it during upgrades.

Screenshots have two different meanings. Patrol 4.10.0 plus patrol_cli 4.8.0 supports Android native screenshots automatically on failure when screenshot_on_failure: true is set, and on demand with await $.takeNativeScreenshot('tag'). Local patrol test pulls screenshots to <test-directory>/screenshots, unless you override --screenshots-output-dir. On device farms, you must configure the farm to collect the device path.

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

void main() {
  patrolTest('captures checkout confirmation evidence', ($) async {
    await $.pumpWidgetAndSettle(const MaterialApp(home: Text('Order confirmed')));
    await $('Order confirmed').waitUntilVisible();
    await $.takeNativeScreenshot('order-confirmed');
  });
}

Video recording is available with --record-video for Android emulators and iOS simulators. Patrol docs say videos are saved as .mp4 files under <test-directory>/videos, with --video-output-dir available. Physical iOS devices are not supported for this feature, and unsupported devices skip recording while the test still runs. On web, Patrol delegates video recording to Playwright through --web-video.

patrol test --record-video
patrol test --record-video --video-output-dir build/patrol-videos
patrol test --device chrome --web-video=retain-on-failure

The failure mode to watch: screenshot names and test names that contain spaces, commas, slashes, or colons can become awkward on device farms. Patrol docs recommend build-time test discovery with emit_test_manifest for reliable farm screenshots because generated per-test names are URL-safe and filesystem-safe.

Web Testing Through Playwright

Patrol 4.0 added web support, and the CLI docs say patrol test --device chrome runs Flutter web tests in Chromium through Playwright. The test results are generated in test-results/. Some mobile flags do not apply on web: --flavor, --uninstall, --clear-permissions, and --full-isolation.

patrol test --device chrome
patrol test --device chrome --web-headless --web-reporter=html

Patrol's changelog shows that 4.8.0 wired additional Playwright launch and context options into the web runner through matching patrol_cli --web-* flags, including options for browser channel, executable path, slow motion, traces, screenshots, storage state, proxy, locale, geolocation, and viewport. Verify exact flag names with patrol test --help in your installed CLI before committing a new CI switch.

Do not assume a passing web run proves mobile behavior. Web support is valuable for browser-specific Flutter regressions, file upload flows, dialogs, storage state, and fast feedback. It does not validate Android permission behavior, iOS notification behavior, or native platform views on a handset.

Device Farms And CI Architecture

Patrol's CI docs are candid about local emulator pain. They recommend device farms such as Firebase Test Lab, emulator.wtf, or others for many cases, while noting that Codemagic can prepare APKs for upload. The docs describe Firebase Test Lab as a good choice for most projects and explain that you upload the main app and the test app, select devices, and get results plus video after the run.

EnvironmentGood forCaution
Developer machineWriting and debugging one targetState leakage between repeated runs.
GitHub Actions Android emulatorOccasional smoke checksSlow startup and random emulator instability.
macOS runner iOS SimulatoriOS simulator smokemacOS minutes are expensive, and simulator is not a physical device.
Firebase Test LabAndroid and iOS device matrixRequires packaging native test artifacts correctly.
BrowserStack or Sauce LabsCross-device reporting and fleet managementCapabilities and naming need careful setup.
In-house farmSpecialized hardware or camera workflowsHigh maintenance burden.

For Android Firebase Test Lab, Patrol docs show building two APKs with patrol build android --target patrol_test/example_test.dart, then running gcloud firebase test android run as an instrumentation test. To collect native failure screenshots, add --directories-to-pull=/sdcard/Download/screenshots.

patrol build android --target patrol_test/checkout_test.dart
gcloud firebase test android run \
  --type instrumentation \
  --use-orchestrator \
  --app build/app/outputs/apk/debug/app-debug.apk \
  --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk \
  --environment-variables clearPackageData=true \
  --directories-to-pull=/sdcard/Download/screenshots

For iOS Firebase Test Lab, Patrol docs show building for simulator or physical devices, then packaging the app under test, test instrumentation app, and .xctestrun file into a zip. If the .xctestrun file has a different iOS version in its name than the Test Lab device, the docs say to rename it so the version matches.

GitHub Actions Starter Workflow

A practical GitHub Actions workflow should not try to cover every device on every pull request. Build or run a small target, archive logs and artifacts, and let the device farm own the matrix. The exact Flutter setup action should be verified in your repo, but the core GitHub action majors below are the current ones.

name: patrol-smoke

on:
  pull_request:
  workflow_dispatch:

jobs:
  android-artifact:
    runs-on: ubuntu-latest
    timeout-minutes: 45
    steps:
      - uses: actions/checkout@v7
      - uses: subosito/flutter-action@v2
        with:
          channel: stable
      - run: flutter pub get
      - run: flutter pub global activate patrol_cli
      - run: patrol doctor
      - run: patrol build android --target patrol_test/login_test.dart
      - uses: actions/upload-artifact@v7
        if: always()
        with:
          name: patrol-android-${{ github.run_id }}
          path: |
            build/app/outputs/apk/debug/*.apk
            build/app/outputs/apk/androidTest/debug/*.apk

This workflow builds artifacts rather than running a flaky emulator. A second job can upload those APKs to Firebase Test Lab, BrowserStack, Sauce Labs, or another farm. If you do run patrol test directly in CI, add timeouts, capture patrol_test/screenshots, patrol_test/videos, and native logs, and avoid parallel jobs that fight over one emulator.

Build-Time Test Discovery And Fast Reruns

Patrol's build-time test discovery is experimental but important for CI and farms. With patrol.emit_test_manifest: true, already-built tests can be rerun without rebuilding through patrol test-without-building. The docs emphasize that this is a separate command because nothing is bundled, compiled, or reinstalled. Options that shape a build, such as --target, --tags, and --dart-define, do not apply to test-without-building.

patrol:
  app_name: QA Skills Demo
  emit_test_manifest: true
  android:
    package_name: sh.qaskills.demo
  ios:
    bundle_id: sh.qaskills.demo
patrol build ios --emit-test-manifest
patrol test-without-building
patrol test-without-building --only "checkout_test pays with stored card"

This is useful when you are debugging the same failing native test repeatedly and the app code has not changed. After changing Dart or native code, rebuild. The hidden failure mode is stale artifacts: the runner faithfully executes what was last built, not what you wish it built.

Failure Mode: The Permission Test Passes Alone But Fails In The Suite

Symptom: patrol test --target patrol_test/location_test.dart passes on a clean emulator. The full suite fails because the permission dialog no longer appears, or the app starts in an already-granted state. Diagnosis: the test depends on mutable OS permission state. Flutter state reset is not enough because the permission lives outside your widget tree.

Fix it by owning isolation. On Android, configure clearPackageData for test runs that need app data reset, and design permission tests to handle either state when the product behavior allows it. On iOS Simulator, evaluate --full-isolation, but remember the docs mark it experimental. For device farms, rely on fresh devices where possible and keep the permission test in a small group so its preconditions are obvious.

A second version of this failure happens with notification tests. The app schedules a local notification, the test immediately opens notifications, and the notification is not there yet. The bad fix is a fixed ten-second sleep. The better fix is to instrument the app so the test can wait for a product-visible state before opening the tray, or schedule the notification through a deterministic test hook.

What To Ask An AI Coding Agent To Do

Give the agent a Patrol-specific request, not a generic E2E request. Name the test file under patrol_test/, the flow, the target platform, the native action, and the proof command. For example: "Add patrol_test/notification_permission_test.dart. It should launch the app, trigger notification permission, grant it with Patrol native automation, create a local notification, open the notification tray, tap the notification, and assert the app navigates to the notification detail screen. Verify with patrol test --target patrol_test/notification_permission_test.dart."

Also ask it to add missing keys or semantics labels in the app if the current UI lacks stable selectors. That is not test pollution when the selector maps to a real product contract. It is the same discipline as accessible labels. What you should not accept is an agent-generated test that relies on control order, arbitrary waits, or a final assertion that only checks "no exception."

For qaskills.sh users, ready-made QA skills can be installed with the qaskills CLI, but Patrol setup still requires your app IDs, signing expectations, device strategy, and CI runner constraints. Those are project facts, not generic library facts.

Patrol Versus integration_test, Appium, Espresso, And XCUITest

Patrol sits in a useful middle. It keeps test authoring in Dart and Flutter concepts, but reaches native capabilities that plain integration_test cannot. Compared with Appium, Patrol is less black-box and more Flutter-native. Compared with Espresso or XCUITest directly, Patrol lets one Dart test express cross-platform intent while still running through native test infrastructure.

ToolBest fitTradeoff
integration_testFlutter-only app flows, screenshots, performance tracesCannot drive native OS UI.
PatrolFlutter app plus native permissions, notifications, WebViews, settingsRequires Patrol CLI and native project setup.
Appium 2Cross-language black-box mobile automation and external appsSlower feedback and more selector complexity.
EspressoAndroid-native app internals and tight Android runner integrationAndroid only.
XCUITestiOS-native UI fidelity and Apple toolingiOS only, Swift or Objective-C test layer.

The decision is not ideological. If your QA team writes Dart and the app is Flutter, Patrol is often the highest-leverage native E2E layer. If your organization centralizes mobile automation across React Native, native iOS, native Android, and Flutter, Appium may be easier to standardize. If a payment provider or OS feature breaks only on iOS, a focused XCUITest may be the fastest way to get a precise answer.

Frequently Asked Questions

Is Patrol Flutter testing actively maintained?

Yes. I verified the official docs, pub.dev pages, changelog, and GitHub repository. The patrol package is active at 4.10.0 on pub.dev, and patrol_cli is active at 4.8.0. The recent changelog includes build-time test discovery, web support through Playwright, Swift Package Manager work, and Android native failure screenshots. Treat older setup posts carefully because the default test directory changed to patrol_test.

Can I run Patrol tests with flutter test?

No for Patrol UI tests. The official install docs say Patrol CLI is necessary and flutter test will not work for those UI tests. Use patrol test for normal runs and patrol develop --target ... for the hot restart development loop. Keep flutter test for unit tests, widget tests, and non-Patrol Flutter tests.

When should I choose Patrol instead of Appium?

Choose Patrol when the app is Flutter, the team is comfortable in Dart, and the flow needs native automation around a Flutter app: permissions, notifications, WebViews, settings, or platform views. Choose Appium when you need a black-box framework across multiple app stacks, multiple programming languages, or external apps. Patrol is usually a sharper fit for Flutter-owned E2E coverage. Appium is broader organizational infrastructure.

Does Patrol work on web?

Yes. Patrol supports Flutter web testing through Playwright, and patrol test --device chrome runs tests in Chromium with results in test-results/. Web support has its own flags and limitations. Mobile-only options such as --flavor, --uninstall, --clear-permissions, and --full-isolation do not apply on web. Use web runs for browser-specific Flutter behavior, not as proof of native mobile behavior.