Integration testing Flutter apps end to end
Integration tests run your real app against a real or simulated environment. They catch bugs that unit tests miss — navigation, deep links, platform channels, and full user flows.
Setup
dev_dependencies:
integration_test:
sdk: flutter
flutter_test:
sdk: flutter
patrol: ^3.0.0 # Optional but strongly recommended
Create integration_test/app_test.dart:
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('login and view profile', (tester) async {
app.main(); // Launch the full app
await tester.pumpAndSettle();
// Should land on login screen
expect(find.byKey(const Key('login_screen')), findsOneWidget);
// Enter credentials
await tester.enterText(find.byKey(const Key('email_field')), '[email protected]');
await tester.enterText(find.byKey(const Key('password_field')), 'password123');
await tester.tap(find.byKey(const Key('login_button')));
await tester.pumpAndSettle();
// Should navigate to home
expect(find.byKey(const Key('home_screen')), findsOneWidget);
});
}
Run:
flutter test integration_test/app_test.dart -d <device-id>
# Or against an emulator
flutter test integration_test/ -d emulator-5554
Using semantic keys
Add Key identifiers to interactive widgets:
TextField(
key: const Key('email_field'),
controller: emailController,
)
ElevatedButton(
key: const Key('login_button'),
onPressed: _submit,
child: const Text('Log in'),
)
Scaffold(
key: const Key('home_screen'),
// ...
)
Handling async operations
// pumpAndSettle waits for animations and async work to complete
await tester.pumpAndSettle();
// If a network call takes time, you may need to pump manually
await tester.pump(const Duration(seconds: 2));
// Or use a custom waiter
await tester.pumpAndSettle(const Duration(seconds: 5));
Page Object Model
For maintainable tests, wrap each screen in a page object:
class LoginPage {
final WidgetTester tester;
LoginPage(this.tester);
Future<void> enterEmail(String email) async {
await tester.enterText(find.byKey(const Key('email_field')), email);
}
Future<void> enterPassword(String password) async {
await tester.enterText(find.byKey(const Key('password_field')), password);
}
Future<HomePage> submit() async {
await tester.tap(find.byKey(const Key('login_button')));
await tester.pumpAndSettle();
return HomePage(tester);
}
}
class HomePage {
final WidgetTester tester;
HomePage(this.tester);
bool get isVisible => find.byKey(const Key('home_screen')).evaluate().isNotEmpty;
Future<void> navigateToProfile() async {
await tester.tap(find.byKey(const Key('profile_tab')));
await tester.pumpAndSettle();
}
}
// Cleaner test:
testWidgets('full login flow', (tester) async {
app.main();
await tester.pumpAndSettle();
final homePage = await LoginPage(tester)
..await enterEmail('[email protected]')
..await enterPassword('password');
final home = await loginPage.submit();
expect(home.isVisible, isTrue);
});
Mocking network in integration tests
For deterministic tests that don't hit real APIs:
// Use a mock server (shelf package)
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async {
// Start a local mock server
final server = await MockServer.start(port: 8080);
server.register('GET /products', ProductFixtures.list);
// Configure app to use localhost:8080
});
}
Or override the Dio instance via dependency injection:
app.main(apiBaseUrl: 'http://localhost:8080');
Running on CI (GitHub Actions)
- name: Run integration tests (Android)
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
script: flutter test integration_test/ -d emulator-5554
# iOS requires macOS runner:
- name: Run integration tests (iOS)
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- run: flutter test integration_test/ -d iPhone-15
Patrol for native interactions
patrol enables interactions that flutter_test can't handle — native permission dialogs, notifications, deep links:
patrolTest(
'handles notification permission prompt',
($) async {
await $.pumpWidgetAndSettle(const MyApp());
await $(NotificationsButton).tap();
// Handle native iOS/Android permission dialog
if (await $.native.isPermissionDialogVisible()) {
await $.native.grantPermissionWhenInUse();
}
expect($(NotificationsEnabledBadge), findsOneWidget);
},
);
Common pitfalls
Calling pumpAndSettle without a timeout on slow tests. The default timeout is 100 pumps. If your network call takes longer, the test fails with "pump still active". Add an explicit timeout: await tester.pumpAndSettle(const Duration(seconds: 10)).
Relying on text instead of keys. find.text('Submit') breaks if the copy changes. find.byKey(const Key('submit_button')) survives copy changes.
Running integration tests against production data. Integration tests should run against a dedicated test environment or mock server, not production. A test that places an order should not create a real order.
Sign in to like, dislike, or report.