Lesson 35 of 77 advanced

Integration Testing & E2E Flows

Testing complete user journeys on real devices — the final layer of confidence

Open interactive version (quiz + challenge)

Real-world analogy

Integration tests are the dress rehearsal before opening night. Unit tests checked each actor knows their lines. Widget tests verified the scenes work. Integration tests run the full show — costume, props, stage, real audience reactions — on the actual theatre.

What is it?

Integration tests (via the integration_test package) run the full Flutter application on a real device or emulator, simulating complete user journeys end-to-end. They provide the highest confidence that the app works as a whole but are the slowest and most expensive tests to maintain.

Real-world relevance

On an offline-first field survey app, an integration test for the sync flow (complete survey offline → go online → verify data synced to server) caught that the Hive database was not flushed before the sync was triggered, causing data loss on low-end Android devices.

Key points

Code example

// integration_test/app_test.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();

  group('Login to Dashboard E2E Flow', () {
    testWidgets('user can log in and reach dashboard', (tester) async {
      app.main();
      await tester.pumpAndSettle();

      // Verify we are on the login screen
      expect(find.byKey(const Key('login-screen')), findsOneWidget);

      // Enter credentials
      await tester.enterText(
        find.byKey(const Key('email-field')),
        'test@example.com',
      );
      await tester.enterText(
        find.byKey(const Key('password-field')),
        'TestPass123!',
      );
      await tester.pump();

      // Tap login
      await tester.tap(find.byKey(const Key('login-submit-btn')));
      await tester.pumpAndSettle(const Duration(seconds: 5));

      // Verify navigation to dashboard
      expect(find.byKey(const Key('dashboard-screen')), findsOneWidget);
      expect(find.text('Welcome, Test User'), findsOneWidget);
    });

    testWidgets('user sees error on invalid credentials', (tester) async {
      app.main();
      await tester.pumpAndSettle();

      await tester.enterText(
        find.byKey(const Key('email-field')),
        'wrong@example.com',
      );
      await tester.enterText(
        find.byKey(const Key('password-field')),
        'wrongpassword',
      );
      await tester.tap(find.byKey(const Key('login-submit-btn')));
      await tester.pumpAndSettle(const Duration(seconds: 5));

      expect(find.text('Invalid credentials'), findsOneWidget);
      expect(find.byKey(const Key('login-screen')), findsOneWidget);
    });
  });
}

// Run with:
// flutter test integration_test/app_test.dart -d <device-id>
// or on Firebase Test Lab:
// gcloud firebase test android run --type instrumentation --app build/app/outputs/apk/debug/app-debug.apk --test build/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk

Line-by-line walkthrough

  1. 1. IntegrationTestWidgetsFlutterBinding.ensureInitialized() — connects the test runner to the device's test harness; must be the first call
  2. 2. app.main() — launches your REAL app, not a test stub; all real plugins, navigation, and state management initialise
  3. 3. tester.pumpAndSettle() — waits for the splash screen and initial navigation animations to complete
  4. 4. find.byKey(const Key('login-screen')) — Key-based finders are essential in integration tests; text-based finders break on localisation changes
  5. 5. tester.enterText(...) and tester.tap(...) — simulate real user input; same API as widget tests but running against a real app
  6. 6. pumpAndSettle(const Duration(seconds: 5)) — allows up to 5 seconds for a real network call to complete and the navigation animation to settle
  7. 7. expect(find.byKey(const Key('dashboard-screen')), findsOneWidget) — confirms navigation succeeded
  8. 8. flutter test integration_test/ -d — runs against a specific connected device or emulator

Spot the bug

void main() {
  testWidgets('login flow works', (tester) async {
    app.main();
    await tester.pump();

    await tester.enterText(find.byType(TextField), 'user@test.com');
    await tester.tap(find.byType(ElevatedButton));
    await tester.pump();

    expect(find.byType(DashboardScreen), findsOneWidget);
  });
}
Need a hint?
Three issues: missing binding initialisation, timing problem after launch, and timing problem after login.
Show answer
Bug 1: Missing IntegrationTestWidgetsFlutterBinding.ensureInitialized() before main() — the test runner cannot communicate with the app without this. Bug 2: tester.pump() after app.main() is insufficient — the app needs pumpAndSettle() to complete its startup animations and initial navigation. Bug 3: tester.pump() after tapping login does not wait for the real network call — use pumpAndSettle(const Duration(seconds: 10)) to wait for the auth response and navigation animation.

Explain like I'm 5

Imagine testing a vending machine. Unit tests check each circuit board works in isolation. Widget tests check the button panel lights up correctly. Integration tests put money in, press a button, and check a real Coke falls out — on the actual machine, with real power, real money, real Coke. It takes longer but it is the most realistic.

Fun fact

Firebase Test Lab runs your integration tests on physical devices — not emulators — across dozens of real Android and iOS device models simultaneously. Google uses the same infrastructure to test their own apps.

Hands-on challenge

Design (do not necessarily implement) an integration test plan for a feature you have built: identify the 3 most critical user flows, specify what device(s) you would run on, and describe what you would do if pumpAndSettle() timed out.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery