Flutter Integration Testing: Driving Real Apps on Real Devices
A hands-on guide to Flutter integration testing with the integration_test package: writing IntegrationTestWidgetsFlutterBinding tests, running them with flutter test on device and on the web, capturing reports and traces, and deciding what belongs in an integration test versus a widget test.
Published on • October 10, 2026
AI Assistant

Unit tests verify your logic. Widget tests verify your tree. Neither one verifies that your app works — that a tap on the FAB actually persists a row, that a deep link lands on the right screen, that a form survives a rotation. Flutter has three testing layers, and integration testing is the one that closes that gap by running your app end to end on a real target.
This post covers the middle layer of the Flutter testing pyramid in depth: the integration_test package, how it differs from flutter_test and flutter drive, how to run the same suite against Android, iOS, and the web, and how to wire it into CI without making every pull request take twenty minutes.
In this tutorial, you will learn how to:
- Set up the
integration_testpackage and understandIntegrationTestWidgetsFlutterBinding - Write an end-to-end test that exercises real persistence and real navigation
- Run integration tests with
flutter test --integration-teston devices and emulators - Run the same suite against Flutter web with
--platform chrome - Capture JSON reports, coverage, and Chrome traces for debugging failures
- Split fast smoke tests from slow full flows so CI stays tolerable
Key technologies: integration_test, flutter_test, IntegrationTestWidgetsFlutterBinding, flutter test, flutter drive, --dart-define, --machine.
Prerequisites
- Flutter SDK 3.x with an existing app that has at least one screen with observable state
- An Android emulator, iOS simulator, or a connected device for on-device runs
- Chrome installed if you want to run the suite on the web
integration_testin yourdev_dependencies
Why a third testing layer
The Flutter testing docs describe three layers, and the distinction is about what is real:
| Layer | What it uses | What it verifies |
|---|---|---|
| Unit test | Pure Dart, no Flutter framework | Business logic, models, services |
| Widget test | In-memory widget tree, no device | Layout, interaction, state updates in the tree |
| Integration test | Real app on a real target | Full flows across platform channels, plugins, and persistence |
A widget test can render your login form and tap the button, but it runs against a headless binding with no platform channel behind it. The moment your button calls SharedPreferences through a plugin, or opens a camera, or talks to a real HTTP stack, the widget test stops proving anything.
That is the seam integration tests cover.
Setting up integration_test
Add the package to dev_dependencies:
dev_dependencies:
integration_test:
sdk: flutter
flutter_test:
sdk: flutter
It ships with the SDK, so there is no version to pin. Next, create a integration_test/ directory at your project root — the location matters, because flutter test looks there by default:
my_app/
├── integration_test/
│ └── app_test.dart
├── lib/
│ └── main.dart
└── test/
└── widget_test.dart
The entry point of an integration test looks almost identical to a widget test, with two differences:
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('increments counter and persists across restart', (tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('0'), findsOneWidget);
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
expect(find.text('1'), findsOneWidget);
});
}
IntegrationTestWidgetsFlutterBinding.ensureInitialized() swaps the standard AutomatedTestWidgetsFlutterBinding for one that talks to the host machine. That binding is what allows the test process to collect results, send trace events, and — critically — run against a real app binary rather than an in-memory tree.
Everything else (testWidgets, finders, matchers, pumpAndSettle) is the same API you already know from widget tests. That is deliberate: the point of integration_test is that you write normal Flutter tests and get device execution.
Testing something that only works on a device
Here is a test that would be meaningless as a widget test — it exercises real persistence through a plugin:
testWidgets('todo survives an app restart', (tester) async {
await SharedPreferences.setMockInitialValues({});
await tester.pumpWidget(const TodoApp());
await tester.enterText(find.byType(TextField), 'Buy milk');
await tester.tap(find.byType(FloatingActionButton));
await tester.pumpAndSettle();
expect(find.text('Buy milk'), findsOneWidget);
// Simulate the process going away and coming back.
await tester.pumpWidget(const SizedBox());
await tester.pumpWidget(const TodoApp());
expect(find.text('Buy milk'), findsOneWidget);
});
On a widget test, SharedPreferences would be mocked and the second half of that test would prove nothing. Running it through integration_test against a real build means the plugin’s platform channel actually fires.
The same applies to anything crossing the Dart/platform boundary: file system writes, secure storage, permissions dialogs, notifications, and third-party SDK initialization.
Running integration tests
On a device or emulator
# Run every test in integration_test/ on the connected target
flutter test integration_test
# Or point at a specific file
flutter test integration_test/app_test.dart
# Pick a target when several are connected
flutter test -d <device-id> integration_test
List available targets with flutter devices.
The command builds your app, installs it, launches it, and streams results back to the terminal. A failure prints the same assertion output you would see from a widget test, plus the device it happened on.
Passing configuration in
Use --dart-define to point the same test suite at staging or production:
flutter test integration_test \
--dart-define=API_BASE=https://staging.example.com \
--dart-define=FLAVOR=staging
Inside your app, read it with String.fromEnvironment. This is the standard way to keep environment-specific values out of the test source.
On Flutter web
One of the strongest reasons to prefer integration_test over the older flutter drive workflow is that the same file runs on the web:
flutter test integration_test --platform chrome
Web support is a real advantage. It catches browser-only failures — a DateTime parsing difference, a plugin with no web implementation, a layout that overflows at a different pixel ratio — without a second suite.
Generating a machine-readable report
For CI, add --machine and redirect the JSON output:
flutter test integration_test --machine > results.json
Each event in the stream carries the test name, result, and timing, which is what a reporter or test aggregator needs. Pair it with a tool that understands the Flutter test event protocol rather than parsing it by hand.
What to do when a test fails on CI but passes locally
Integration tests fail for reasons widget tests never do: the device was slow, a plugin needed a permission the CI image did not grant, the app under test had a stale install, or a network call raced the assertion. Three habits cut the flakiness down:
Wait for the app, not for a duration. Always await tester.pumpAndSettle() after a navigation or a state change. Fixed pump(Duration(...)) calls are the single most common source of timing flakiness on slower emulators.
Reset state between tests. An integration test runs against a real store. If test A writes a row and test B asserts the list is empty, B will fail the second time you run the suite. Clear the store in setUp, or make each test assert on data it created itself.
Give CI a real target. On GitHub Actions and similar hosts, use a hardware-accelerated emulator (KVM on Linux runners) or a hosted device farm. A software-rendered emulator will make pumpAndSettle time out often enough to make the suite useless.
Structuring the suite so CI stays fast
A full integration suite will eventually take long enough to slow every merge. The fix is to split by intent:
Smoke tests — a handful of tests that launch the app, verify the home screen renders, and complete one core flow. Run these on every pull request. They should finish in a couple of minutes.
Full regression — the complete suite, run on a schedule or before a release. This is where cross-device coverage lives: run the web target and one iOS and one Android target, and accept that it takes longer.
In practice that looks like two jobs:
# Pull request: fast smoke only
- run: flutter test integration_test/app_test.dart
# Nightly: full suite on all targets
- run: flutter test integration_test --platform chrome
- run: flutter test integration_test -d emulator-5554
The discipline that makes this work is keeping the smoke file small and genuinely smoke-shaped: launch, verify, one flow, exit. Anything that touches a payment, a file upload, or a permission prompt belongs in the regression bucket.
Integrating with existing widget tests
Do not treat integration tests as a replacement for widget tests — they are slower, harder to debug, and less precise. A good split:
- Unit tests for parsing, formatting, and business rules
- Widget tests for every screen’s states: loading, empty, error, populated
- Integration tests for one or two end-to-end flows per feature, plus app launch
If you find yourself writing twenty integration tests for one screen, you have testing widgets in the wrong layer. Move the state coverage down to widget tests and keep the integration suite for the paths that genuinely cross the platform boundary.
There is also a practical debugging angle: widget tests run in milliseconds and let you set breakpoints in the normal way, while integration tests require attaching a debugger to a running app. Reserve the slow layer for the failures only it can find.