← Articles

Integration testing Flutter apps end to end

By Charlin Joe · 1 March 20250 views

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.

Integration testing Flutter apps end to end — ANN Tech