Flutter Integration Testing: Real Devices, Emulators, and the integration_test Package

Flutter Integration Testing: Real Devices, Emulators, and the integration_test Package

Flutter integration tests run your full app on a real device or emulator — no mocks, no shortcuts. This guide covers the integration_test package from scratch: setup, writing tests, managing app state between tests, running on CI, and gotchas that will save you hours of debugging.

Key Takeaways

  • integration_test replaces the old flutter_driver package and shares the flutter_test API
  • Tests run inside the app process, giving you direct access to the widget tree
  • Use IntegrationTestWidgetsFlutterBinding.ensureInitialized() in every test file
  • setUp and tearDown run around each test; setUpAll and tearDownAll wrap the group
  • CI requires an emulator or a connected device — cloud device farms solve this

Widget tests are fast and isolated, but they run in a simulated environment. Integration tests run your actual app — on a real Android emulator, iOS simulator, or physical device — and verify that all the pieces work together. They are the only tests that catch issues like: "the app builds fine but crashes on Android 12 with a specific screen size."

This guide covers everything you need to know to write, run, and maintain Flutter integration tests in 2026.

Widget Tests vs Integration Tests

Before writing a single line, understand what you are buying:

Aspect Widget Test Integration Test
Runs on Headless Flutter engine Real device or emulator
Speed ~30ms per test ~2–10s per test
Real navigation No Yes
Real plugins No Yes
Real HTTP No (mock it) Yes
Real camera/GPS No Yes
Best for UI logic, state, rendering User flows, plugin behavior, real data

Integration tests are slow. Run them in CI, not on every file save.

Setting Up the integration_test Package

integration_test is part of the Flutter SDK. Add it to pubspec.yaml:

dev_dependencies:
  flutter_test:
    sdk: flutter
  integration_test:
    sdk: flutter

Create the test directory structure:

integration_test/
  app_test.dart
  login_flow_test.dart
  checkout_flow_test.dart
test_driver/
  integration_test.dart   ← driver entry point (required for some CI setups)

The driver file is minimal:

// test_driver/integration_test.dart
import 'package:integration_test/integration_test_driver.dart';

Future<void> main() => integrationDriver();

Writing Your First Integration Test

Every integration test file needs the binding initialized before anything else:

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();

  group('Counter app', () {
    testWidgets('increments counter on button tap', (tester) async {
      // Launch the full app
      app.main();
      await tester.pumpAndSettle();

      // Verify initial state
      expect(find.text('0'), findsOneWidget);

      // Interact
      await tester.tap(find.byIcon(Icons.add));
      await tester.pumpAndSettle();

      // Verify result
      expect(find.text('1'), findsOneWidget);
    });
  });
}

Run it on a connected device or emulator:

# On connected device
flutter test integration_test/app_test.dart

# On specific device
flutter test integration_test/app_test.dart -d emulator-5554

# On iOS simulator
flutter test integration_test/app_test.dart -d "iPhone 15 Pro"

Launching the App Correctly

Calling app.main() runs your real main.dart. For tests, you often want to:

  1. Disable analytics/crash reporting
  2. Use a test backend or local mock server
  3. Clear app state before each test

Create a test-specific entry point:

// lib/main_test.dart
import 'package:flutter/material.dart';
import 'package:my_app/app.dart';
import 'package:my_app/services/api_client.dart';

void main() {
  // Override API base URL for tests
  ApiClient.baseUrl = 'http://10.0.2.2:8080'; // Android emulator localhost

  runApp(const MyApp());
}

Then in your integration tests:

import 'package:my_app/main_test.dart' as app;

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('login flow', (tester) async {
    app.main();
    await tester.pumpAndSettle();
    // ...
  });
}

Managing App State Between Tests

Each testWidgets call within an integration test file shares a running app instance — unlike widget tests, there is no fresh widget tree for each test. You must manage state explicitly.

Option 1: Restart the App

group('login flow', () {
  setUp(() async {
    // Clear shared preferences
    final prefs = await SharedPreferences.getInstance();
    await prefs.clear();
  });

  testWidgets('fresh login succeeds', (tester) async {
    app.main();
    await tester.pumpAndSettle();
    // ...
  });
});

Option 2: Navigate to a Known State

testWidgets('add to cart then checkout', (tester) async {
  // Assume we start logged in
  // Navigate to home explicitly
  final NavigatorState navigator = tester.state(find.byType(Navigator));
  navigator.pushNamedAndRemoveUntil('/home', (route) => false);
  await tester.pumpAndSettle();

  // Now run the test
  await tester.tap(find.text('Product A'));
  // ...
});

Option 3: Use a Test Database

For apps with local storage (SQLite, Hive, etc.), inject a test database that you reset between tests:

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  setUpAll(() async {
    // Initialize test database
    await TestDatabase.initialize();
  });

  setUp(() async {
    // Reset data before each test
    await TestDatabase.reset();
    await TestDatabase.seed([
      Product(id: '1', name: 'Widget', price: 9.99),
      Product(id: '2', name: 'Gadget', price: 29.99),
    ]);
  });

  tearDownAll(() async {
    await TestDatabase.destroy();
  });
}

Testing a Login Flow

Here is a complete, realistic integration test for a login screen:

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  group('Authentication flow', () {
    setUp(() async {
      // Clear saved credentials
      final prefs = await SharedPreferences.getInstance();
      await prefs.remove('auth_token');
    });

    testWidgets('successful login navigates to home screen', (tester) async {
      app.main();
      await tester.pumpAndSettle();

      // Should start on login screen
      expect(find.byType(LoginScreen), findsOneWidget);

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

      // Hide keyboard
      await tester.testTextInput.receiveAction(TextInputAction.done);
      await tester.pump();

      // Tap login button
      await tester.tap(find.byKey(const Key('login-button')));
      await tester.pump(); // start loading

      // Loading indicator appears
      expect(find.byType(CircularProgressIndicator), findsOneWidget);

      // Wait for login to complete
      await tester.pumpAndSettle(const Duration(seconds: 5));

      // Should be on home screen
      expect(find.byType(HomeScreen), findsOneWidget);
      expect(find.byType(LoginScreen), findsNothing);
    });

    testWidgets('invalid credentials shows error message', (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')),
        'wrongpass',
      );

      await tester.tap(find.byKey(const Key('login-button')));
      await tester.pumpAndSettle(const Duration(seconds: 5));

      // Error message visible
      expect(find.text('Invalid email or password'), findsOneWidget);
      expect(find.byType(LoginScreen), findsOneWidget);
    });

    testWidgets('persisted login skips login screen on restart', (tester) async {
      // Pre-seed a valid token
      final prefs = await SharedPreferences.getInstance();
      await prefs.setString('auth_token', 'valid-token-xyz');

      app.main();
      await tester.pumpAndSettle();

      // Skip login, go straight to home
      expect(find.byType(HomeScreen), findsOneWidget);
      expect(find.byType(LoginScreen), findsNothing);
    });
  });
}

Testing a Multi-Screen Flow

testWidgets('complete purchase flow', (tester) async {
  app.main();
  await tester.pumpAndSettle();

  // 1. Browse catalog
  expect(find.byType(ProductListScreen), findsOneWidget);
  expect(find.byType(ProductCard), findsWidgets);

  // 2. Open product detail
  await tester.tap(find.text('Premium Widget').first);
  await tester.pumpAndSettle();
  expect(find.byType(ProductDetailScreen), findsOneWidget);

  // 3. Add to cart
  await tester.tap(find.byKey(const Key('add-to-cart-button')));
  await tester.pumpAndSettle();

  // Confirmation snackbar
  expect(find.text('Added to cart'), findsOneWidget);

  // 4. Go to cart
  await tester.tap(find.byIcon(Icons.shopping_cart));
  await tester.pumpAndSettle();
  expect(find.byType(CartScreen), findsOneWidget);
  expect(find.text('Premium Widget'), findsOneWidget);
  expect(find.text('\$9.99'), findsOneWidget);

  // 5. Proceed to checkout
  await tester.tap(find.text('Checkout'));
  await tester.pumpAndSettle();
  expect(find.byType(CheckoutScreen), findsOneWidget);

  // 6. Fill shipping info
  await tester.enterText(find.byKey(const Key('name-field')), 'Jane Doe');
  await tester.enterText(
    find.byKey(const Key('address-field')),
    '123 Main St',
  );
  await tester.pump();

  // 7. Place order
  await tester.tap(find.text('Place Order'));
  await tester.pumpAndSettle(const Duration(seconds: 10));

  // 8. Order confirmation
  expect(find.byType(OrderConfirmationScreen), findsOneWidget);
  expect(find.textContaining('Order #'), findsOneWidget);
});

Handling Slow Operations and Timeouts

Integration tests interact with real services. Network calls, database writes, and animations all take real time. pumpAndSettle has a default timeout of 10 minutes — but you can and should configure it:

// Custom timeout
await tester.pumpAndSettle(const Duration(seconds: 30));

// Pump with specific intervals for polling
for (int i = 0; i < 10; i++) {
  await tester.pump(const Duration(seconds: 1));
  if (find.byType(HomeScreen).evaluate().isNotEmpty) break;
}
expect(find.byType(HomeScreen), findsOneWidget);

For operations with known durations, pump the exact expected duration:

// Splash screen shows for 2 seconds
await tester.pump(const Duration(seconds: 2));
await tester.pump(); // one more frame for the transition

Working with Platform Plugins

Integration tests can test real platform plugins — camera, location, biometrics — but you need to handle permission dialogs. This is where Patrol (covered in the E2E Patrol post in this series) shines, but with plain integration_test you can use flutter_native_splash or grant permissions in advance via ADB/xcrun:

# Grant camera permission on Android emulator before running tests
adb shell pm grant com.example.myapp android.permission.CAMERA

# Grant location permission on iOS simulator
xcrun simctl privacy booted grant location-always com.example.myapp

Scrolling in Integration Tests

// Scroll a list to find a widget
await tester.scrollUntilVisible(
  find.text('Item 42'),
  300.0,
  scrollable: find.byType(Scrollable).first,
);
await tester.pump();
expect(find.text('Item 42'), findsOneWidget);

// Pull to refresh
await tester.fling(
  find.byType(RefreshIndicator),
  const Offset(0, 300),
  1000,
);
await tester.pumpAndSettle();

Running Integration Tests in CI

GitHub Actions (Android Emulator)

name: Integration Tests

on: [push, pull_request]

jobs:
  integration-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.22.0'

      - name: Enable KVM
        run: |
          echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
          sudo udevadm control --reload-rules
          sudo udevadm trigger --name-match=kvm

      - name: Run integration tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 33
          arch: x86_64
          profile: Nexus 6
          script: flutter test integration_test/

GitHub Actions (iOS Simulator)

jobs:
  integration-tests-ios:
    runs-on: macos-14
    steps:
      - uses: actions/checkout@v4
      - uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.22.0'

      - name: Start simulator
        run: |
          UDID=$(xcrun simctl create "iPhone 15" "iPhone 15" "17.2")
          xcrun simctl boot $UDID
          echo "SIMULATOR_UDID=$UDID" >> $GITHUB_ENV

      - name: Run integration tests
        run: |
          flutter test integration_test/ -d $SIMULATOR_UDID

Tips for Maintainable Integration Tests

1. Extract page objects

Instead of scattering find.byKey(const Key('login-button')) calls everywhere, create page object classes:

class LoginPage {
  final WidgetTester tester;
  LoginPage(this.tester);

  Future<void> enterEmail(String email) async {
    await tester.enterText(find.byKey(const Key('email-field')), email);
    await tester.pump();
  }

  Future<void> enterPassword(String password) async {
    await tester.enterText(find.byKey(const Key('password-field')), password);
    await tester.pump();
  }

  Future<void> tapLogin() async {
    await tester.tap(find.byKey(const Key('login-button')));
    await tester.pumpAndSettle();
  }
}

// Usage
final loginPage = LoginPage(tester);
await loginPage.enterEmail('user@example.com');
await loginPage.enterPassword('secret');
await loginPage.tapLogin();

2. Use meaningful test names

// Bad
testWidgets('test1', (tester) async { ... });

// Good
testWidgets('user with saved credentials bypasses login on cold start', (tester) async { ... });

3. Do not share state between test files

Each integration test file gets a fresh app start. Do not assume one file's state carries over to another.

4. Keep integration tests focused on flows, not units

If you find yourself testing a single button in an integration test, move it to a widget test. Integration tests are for multi-screen user journeys.

Beyond Manual Execution: Cloud Integration Testing

Running integration tests locally works during development. But devices are expensive, emulators are slow to provision in CI, and flaky tests on shared CI machines eat engineering time.

HelpMeTest provides cloud-hosted Flutter test execution on real devices — no emulator setup, no CI configuration headaches. Upload your integration test suite and get results across multiple Android and iOS versions, with usage-based pricing at $0.003 per test run. AI-powered flakiness detection identifies tests that pass locally but fail in CI — before they block your release.

Focus on writing tests, not babysitting infrastructure.

Read more

Start now free