Flutter Lesson 68 of 83 4 min read
Integration Testing in Flutter
Test complete user journeys in Flutter with the integration_test package: run the real app on a device, tap through screens and verify.
On this page
Unit and widget tests check pieces in isolation. An integration test runs the whole app on a real device or emulator, taps through it like a user, and checks that everything works together: navigation, plugins, storage and state.
They are slow, so write them only for the journeys that must never break: sign in, add to cart and check out, create and save a record.
Setup #
Add the package from the Flutter SDK:
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
Create a folder named integration_test at the project root, next to lib and test.
A first test #
// integration_test/app_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:my_app/main.dart' as app;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('a user can add a note and see it in the list', (tester) async {
app.main();
await tester.pumpAndSettle();
// Starts empty.
expect(find.text('No notes yet'), findsOneWidget);
// Open the editor.
await tester.tap(find.byIcon(Icons.add));
await tester.pumpAndSettle();
// Type and save.
await tester.enterText(find.byType(TextField), 'Buy milk');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
// Back on the list, with the new note.
expect(find.text('Buy milk'), findsOneWidget);
expect(find.text('No notes yet'), findsNothing);
});
}
The API is the same WidgetTester you used for widget tests. The differences are the first line, which connects the test to a real device, and that you start the real app with app.main().
Running it #
flutter test integration_test # all tests, on the connected device
flutter test integration_test/app_test.dart # one file
flutter test integration_test -d emulator-5554 # a specific device
You will see the app open and operate itself.
A longer journey #
testWidgets('sign in, add to cart, check out', (tester) async {
app.main();
await tester.pumpAndSettle();
// Sign in
await tester.enterText(find.byKey(const Key('email')), 'test@example.com');
await tester.enterText(find.byKey(const Key('password')), 'password123');
await tester.tap(find.widgetWithText(FilledButton, 'Sign in'));
await tester.pumpAndSettle();
expect(find.text('Products'), findsOneWidget);
// Scroll to a product and add it
await tester.scrollUntilVisible(find.text('Trail backpack'), 300);
await tester.tap(find.text('Trail backpack'));
await tester.pumpAndSettle();
await tester.tap(find.text('Add to cart'));
await tester.pumpAndSettle();
// Check out
await tester.tap(find.byTooltip('Cart'));
await tester.pumpAndSettle();
expect(find.text('Trail backpack'), findsOneWidget);
await tester.tap(find.text('Check out'));
await tester.pumpAndSettle();
expect(find.text('Order placed'), findsOneWidget);
});
Making tests reliable #
Integration tests have a reputation for being flaky. Most of that comes from four causes.
1. A real backend. A slow or changed server fails the test for reasons unrelated to your code. Run against a test server with known data, or start the app with fake repositories:
// lib/main.dart
void main() => runApp(MyApp(api: RealApi()));
// integration_test/app_test.dart
await tester.pumpWidget(MyApp(api: FakeApi()));
2. State left over from the last run. Clear saved data at the start of each test, or use a fresh account.
3. Waiting for the wrong thing. pumpAndSettle waits until animations stop. It does not wait for a network response on its own if nothing is animating, and it times out if something animates for ever. To wait for a specific widget:
Future<void> waitFor(WidgetTester tester, Finder finder,
{Duration timeout = const Duration(seconds: 10)}) async {
final end = DateTime.now().add(timeout);
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 100));
if (finder.evaluate().isNotEmpty) return;
}
throw TestFailure('Timed out waiting for $finder');
}
4. Fragile finders. Text changes with wording and language. Give important widgets a Key and find by that.
System dialogs #
Permission prompts, the photo picker and notifications are drawn by the operating system, outside Flutter, so tester cannot tap them. Options:
- Grant permissions before the test, with
adb shell pm granton Android. - Use the
patrolpackage, which extends integration tests with the ability to interact with native dialogs.
Measuring performance #
An integration test can record frame timings while it scrolls, to catch performance regressions.
final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
await binding.traceAction(() async {
await tester.fling(find.byType(ListView), const Offset(0, -3000), 5000);
await tester.pumpAndSettle();
}, reportKey: 'scrolling');
Run this in profile mode on a real device.
Running on many devices #
Emulators on your machine cover a few configurations. Firebase Test Lab and similar services run your integration tests on dozens of real devices and return videos and logs. They are usually part of a CI pipeline.
How many? #
A sensible split for most apps:
| Kind | Share | Covers |
|---|---|---|
| Unit | Most | Logic, models, state holders |
| Widget | Many | Screens in each of their states |
| Integration | A handful | The three to five journeys the business depends on |
Try it yourself #
Write one integration test for an app you have built in this course. It should start the app, perform the main task from beginning to end across at least two screens, and check the final result. Run it on an emulator and watch it work.