Integration Test #
The top layer of your testing pyramid is the integration test (often called end-to-end / E2E testing). Unlike unit tests and widget tests which simulate logic and UI in isolated RAM memory, integration tests run your actual app inside real devices, Android emulators, or iOS simulators. These tests precisely mimic real user behavior: tapping screens, waiting for real backend API server responses, reading physical databases, and interacting with native components outside Flutter.
Flutter provides a built-in package named integration_test, but that package has a fundamental limitation: it can only control elements inside the Flutter view canvas. When your app triggers native operating systems — like showing camera permission dialogs, requesting location access, opening WebViews for third-party OAuth logins, or displaying system notifications in the notification drawer — Flutter’s built-in integration test will stall because it doesn’t have access to the native operating system. To overcome this critical limitation, you use Patrol, a very robust modern integration testing framework developed by LeanCode.
Comparison: Patrol vs integration_test #
Before diving into the configuration steps, it’s very important to understand why Patrol is the industry-standard choice for production-scale Flutter app integration testing:
- Native Dialog Filtering: Patrol has the ability to control native OS interface elements. You can automatically grant or deny location, camera, photo gallery, or system notification permission dialogs while tests run.
- WebView Access: Very useful if your app’s authentication flow uses Google Sign-In, Apple Sign-In, or web-based payment gateways. Patrol can enter native WebViews and type credentials there.
- Operating System Interactions: You can turn WiFi networks on/off, change screen orientation (portrait/landscape), open the system notification drawer, and press native hardware buttons like the Home or Back button.
- Simplified Finder Syntax (
$): Patrol wraps Flutter’s built-in Finder into a much more concise syntax, speeding up test code writing and reducing code line noise (boilerplate code).
Runner Architecture: Patrol vs Built-in integration_test #
One of the reasons Patrol can control native elements while the built-in integration_test can’t lies in its test execution architecture design. Patrol uses a Multi-Runner approach by connecting the Dart Test Runner inside the app with the OS-level Native UI Test Runner (UIAutomator on Android and XCTest on iOS) through a local HTTP server.
graph TD
subgraph Patrol["Patrol Architecture (Multi-Runner)"]
DartRunner["Dart Test Runner (Flutter VM)"] -->|"RPC Communication (Local Host)"| AppService["Patrol App Service (Local HTTP Server)"]
AppService -->|Native Commands| NativeRunner["Native UI Test Runner (UIAutomator / XCTest)"]
NativeRunner -->|System Interaction| OS["Operating System (Android / iOS OS)"]
DartRunner -->|Flutter UI Interaction| FlutterUI["Flutter Engine View"]
end
subgraph BuiltIn["Built-in integration_test Architecture"]
BuiltInDart["Dart Test Runner (Flutter VM)"] -->|Limited Interaction| FlutterUI2["Flutter Engine View"]
BuiltInDart -.->|CANNOT ACCESS| OS2["Operating System (Android / iOS OS)"]
endBy understanding the architecture above, you can see that Patrol acts as a smart bridge uniting the Flutter world with the native OS world in real-time during the testing process.
Installation & Platform Configuration Guide #
Setting up native integration testing requires several configuration steps at the Android and iOS project levels so OS testing instruments are allowed to control your app.
1. Dart Dependency Configuration #
Add the Patrol library to the dev_dependencies section of your pubspec.yaml file:
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
patrol: ^3.14.0
Next, you must install Patrol CLI globally on your development computer. Patrol CLI coordinates the native compilation process and runs the native runners:
# Install Patrol CLI globally
dart pub global activate patrol_cli
# Verify the installation success
patrol --version
2. Android Project Configuration #
First, create the native Android test file in the android/app/src/androidTest/java/com/example/myapp/MainActivityTest.java directory (replace com/example/myapp with your app’s package ID):
package com.example.myapp; // Adjust to your package name
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
import pl.leancode.patrol.PatrolJUnitRunner;
@RunWith(Parameterized.class)
public class MainActivityTest {
@Parameterized.Parameters(name = "{0}")
public static Object[] testCases() {
PatrolJUnitRunner instrumentation =
(PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation();
instrumentation.setUp(MainActivity.class);
instrumentation.waitForPatrolAppService();
return instrumentation.listDartTests();
}
public MainActivityTest(String dartTestName) {
this.dartTestName = dartTestName;
}
private final String dartTestName;
@org.junit.Test
public void runDartTest() {
PatrolJUnitRunner instrumentation =
(PatrolJUnitRunner) InstrumentationRegistry.getInstrumentation();
instrumentation.runDartTest(dartTestName);
}
}
Second, adjust the app’s Gradle configuration file in android/app/build.gradle:
android {
defaultConfig {
// Determine the test instrument runner using Patrol's JUnit Runner
testInstrumentationRunner "pl.leancode.patrol.PatrolJUnitRunner"
testInstrumentationRunnerArguments["clearPackageData"] = "true"
}
testOptions {
// Use the AndroidX Test Orchestrator to isolate each test
execution "ANDROIDX_TEST_ORCHESTRATOR"
}
}
dependencies {
// Add the orchestrator dependency at the Android level
androidTestUtil "androidx.test:orchestrator:1.4.2"
}
3. iOS Project Configuration #
For iOS, you need to ensure the minimum operating system target is iOS 16.0 in the ios/Podfile file:
platform :ios, '16.0'
Then, open your iOS project using Xcode (ios/Runner.xcworkspace). Add a new UI Testing target named RunnerUITests:
- Choose File > New > Target…
- Search and select iOS UI Testing Bundle, click Next.
- Name the target:
RunnerUITests, make sure the selected language is Swift, and click Finish.
Create the UI test code file at ios/RunnerUITests/RunnerUITests.swift:
import XCTest
import patrol
class RunnerUITests: XCTestCase {
func testRunner() {
// Initialize the Patrol runner for iOS
let app = XCUIApplication()
let manager = PatrolUITestsManager(app: app)
manager.setup()
manager.run()
}
}
Test Writing Basics with patrolTest & Custom Finders ($) #
After the installation process is complete, you’re ready to write your first integration test code. In Patrol, you no longer use the testWidgets() function, but the special patrolTest() function which provides the PatrolTester object instantiation parameter (symbolized by the $ character).
Let’s look at the login flow writing comparison between regular integration_test and Patrol:
// integration_test/flows/auth/login_flow_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';
import 'package:flutter_app/main.dart' as app; // Import our app's main function
void main() {
// Patrol-specific binding initialization trigger
patrolTest(
'Successful login flow and entry to the home page',
($) async {
// 1. Run the Flutter app
await $.pumpWidgetAndSettle(app.MyApp());
// 2. Use the $ operator to find elements concisely
// The $(#key) syntax is equivalent to find.byKey(const ValueKey('key'))
await $(#emailInputKey).enterText('[email protected]');
await $.pump();
await $(#passwordInputKey).enterText('passwordMulus123');
await $.pump();
// Tap the login button based on the text string
await $('LOGIN').tap();
// 3. Use waitUntilVisible() for reliable async synchronization
// This method is much more robust than pumpAndSettle() because
// it actively detects widget appearance even with loading processes in the background.
await $(#homeScreenKey).waitUntilVisible();
// 4. Verify the final results
expect($('Welcome, Budi!'), findsOneWidget);
expect($(#loginFormKey), findsNothing);
},
);
}
Concise Guide to Using the $ Operator:
#
$('Text'): Finds a Text widget displaying the string'Text'.$(#inputKey): Finds a widget based onValueKey('inputKey')(using the#symbol).$(ElevatedButton): Finds a widget based on theElevatedButtonclass type.$(#parentKey).$(ListTile): Finds aListTilewidget inside a parent widget with theparentKeykey (finder chaining).
Native OS Element Automation (Permissions & Notifications) #
The main capability that makes Patrol very popular is its interaction ability with native operating systems. Here’s an example of testing a GPS pickup flow that requires native location access permission, plus testing Push Notification reception:
// integration_test/flows/features/gps_location_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';
import 'package:flutter_app/main.dart' as app;
void main() {
patrolTest(
'User grants GPS permission and the app loads the coordinate map',
($) async {
await $.pumpWidgetAndSettle(app.MyApp());
// 1. Tap the button to enter the map navigation feature
await $('Open Store Map').tap();
await $.pumpAndSettle();
// 2. Handle the native operating system permission dialog (Android / iOS)
// Patrol transparently detects the appearance of OS-level native dialogs
if (await $.native.isPermissionDialogVisible()) {
// Automatically approve the "When in Use" location permission
await $.native.grantPermissionWhenInUse();
}
await $.pumpAndSettle();
// 3. Verify the map successfully loads in the Flutter UI after permission is granted
expect($(#mapWidgetKey), findsOneWidget);
},
);
patrolTest(
'The app receives a push notification and responds to notification taps',
($) async {
await $.pumpWidgetAndSettle(app.MyApp());
// Send a trigger or simulate the event triggering a push notification from the server
await $('Trigger New Promo').tap();
await $.pumpAndSettle();
// 1. Open the native operating system notification drawer
await $.native.openNotifications();
// 2. Check whether there's an incoming notification from our app
final bool hasNotif = await $.native.containsNotificationWhere(
appName: 'Our Store',
titleContains: 'Special Discount',
);
expect(hasNotif, isTrue);
// 3. Tap the first incoming notification
await $.native.tapOnNotificationByIndex(0);
await $.pumpAndSettle();
// 4. Verify the app opens to the promo detail page
expect($(#promoDetailScreenKey), findsOneWidget);
},
);
}
Through the $.native methods, you can test real integration cases that were previously impossible to test automatically in Flutter.
Clean Code Structure with the Page Object Model (POM) #
Writing all Finder search logic and interactions directly inside integration test files will make those files very long and hard to maintain. If one day a UI designer changes the page layout (e.g., changing the ValueKey('loginButton') button to ValueKey('submitButton')), you’d have to change all integration test files touching that button.
To overcome this maintenance problem, you must use the Page Object Model (POM) pattern. POM is a design pattern where you separate UI interaction logic into special classes (Page Classes) representing one visual app page. Your test files only call methods from those Page Classes.
Here’s the POM implementation for the Login and Home pages:
// integration_test/pages/login_page.dart
import 'package:patrol/patrol.dart';
import 'package:flutter_test/flutter_test.dart';
class LoginPage {
final PatrolTester $;
// The constructor accepts the tester instantiation
LoginPage(this.$);
// 1. Define all Finders as centralized Getters
PatrolFinder get emailField => $(#emailInputKey);
PatrolFinder get passwordField => $(#passwordInputKey);
PatrolFinder get submitButton => $(#loginButtonKey);
PatrolFinder get errorMessage => $('Email or password is wrong');
// 2. Define all Actions as async functions
Future<void> enterCredentials(String email, String password) async {
await emailField.enterText(email);
await $.pump();
await passwordField.enterText(password);
await $.pump();
}
Future<void> submitLoginForm() async {
await submitButton.tap();
await $.pumpAndSettle();
}
// 3. Define Assertion Verifications
void verifyErrorAlertIsVisible() {
expect(errorMessage, findsOneWidget);
}
void verifyOnLoginPage() {
expect(submitButton, findsOneWidget);
}
}
// integration_test/pages/home_page.dart
import 'package:patrol/patrol.dart';
import 'package:flutter_test/flutter_test.dart';
class HomePage {
final PatrolTester $;
HomePage(this.$);
PatrolFinder get welcomeBanner => $('Welcome');
void verifyOnHomePage() {
expect(welcomeBanner, findsOneWidget);
}
}
Now, notice how clean, structured, and readable your integration test files become when using the POM pattern:
// integration_test/flows/auth/clean_login_flow_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';
import 'package:flutter_app/main.dart' as app;
import '../../pages/login_page.dart';
import '../../pages/home_page.dart';
void main() {
patrolTest(
'Successful login flow using the Page Object Model pattern',
($) async {
await $.pumpWidgetAndSettle(app.MyApp());
// Initialize the pages
final loginPage = LoginPage($);
final homePage = HomePage($);
// Action Steps
await loginPage.enterCredentials('[email protected]', 'password123');
await loginPage.submitLoginForm();
// Verification Steps
homePage.verifyOnHomePage();
},
);
patrolTest(
'Failed login flow shows an error warning message',
($) async {
await $.pumpWidgetAndSettle(app.MyApp());
final loginPage = LoginPage($);
await loginPage.enterCredentials('[email protected]', 'wrongPass');
await loginPage.submitLoginForm();
loginPage.verifyErrorAlertIsVisible();
loginPage.verifyOnLoginPage();
},
);
}
Test Execution & the patrol develop Mode #
To run integration tests using Patrol, make sure your emulator or physical device is connected to the computer. You use the special commands from Patrol CLI:
# Run all integration test files
patrol test
# Run one specific integration test file
patrol test -t integration_test/flows/auth/clean_login_flow_test.dart
Interactive Development: patrol develop #
Writing integration tests often takes a long time because of the heavy native code recompilation process every time you change test code. To overcome this, Patrol provides a very advanced interactive mode called patrol develop:
patrol develop -t integration_test/flows/auth/clean_login_flow_test.dart
Develop mode compiles and installs the app to the device once, then opens an interactive session supporting the Hot Restart feature. When you change test code in your IDE, just press the Hot Restart button in the develop terminal, and the test repeats within seconds without doing the recompilation process from scratch. This is a revolutionary feature significantly cutting integration test development time.
CI/CD Integration with GitHub Actions #
Running integration tests on Continuous Integration (CI) servers like GitHub Actions requires special attention. You need a virtual Android emulator running on the server without a physical interface display (headless mode), plus KVM (Kernel-based Virtual Machine) hardware acceleration so the emulator runs fast and doesn’t trigger timeouts.
Here’s a GitHub Actions workflow file template (.github/workflows/integration_tests.yml) for running Patrol integration tests:
name: Patrol Integration Tests
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
android-integration-tests:
# We use an Ubuntu server because it fully supports KVM acceleration
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Java Environment
uses: actions/setup-java@v3
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Flutter SDK
uses: subosito/flutter-action@v2
with:
flutter-version: '3.x'
channel: 'stable'
- name: Install Patrol CLI
run: dart pub global activate patrol_cli
- name: Enable KVM Hardware 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 & Execute Patrol Tests
# Official emulator runner library for GitHub Actions
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
arch: x86_64
profile: pixel_6
disable-animations: true
# Run the patrol test command inside the emulator instrument
script: patrol test -t integration_test/flows/auth/clean_login_flow_test.dart
Summary #
- Real Apps: Integration tests execute your actual app inside real physical devices or emulators by calling real databases and backend API servers.
- Patrol Advantages: Patrol overcomes the critical limits of Flutter’s built-in
integration_testby supporting native OS element control (like GPS permission dialogs, WebViews, and Push Notifications).- Multi-Runner Architecture: Patrol connects the Dart Runner (Flutter VM) with the native UI Test Runner (UIAutomator/XCTest) using a real-time local HTTP server bridge.
- Page Object Model (POM): Separate page Finders and Actions into special classes to avoid mass test writing damage when interface design changes occur.
- Interactive Mode: Leverage the
patrol developcommand when programming test code to enjoy instant Hot Restart features to cut native binary compilation time.- CI/CD Pipeline: Run integration tests on Ubuntu servers in GitHub Actions workflows leveraging KVM emulator hardware acceleration for optimal performance.