[Flutter] Practical E2E Test Environment Setup Guide

2026-04-11 hit count image

Learn how to set up an integration_test-based E2E test environment in a Flutter project and apply it to a real app using Firebase/GetX/SQLite.

flutter

Overview

E2E (End-to-End) testing in Flutter runs the app on a real device or emulator to verify user flows. While widget tests isolate and test individual components, E2E tests verify that navigation, DB queries, controller initialization, and more work together in a real environment.

This post covers the process of applying E2E tests to a real project using Firebase, GetX, and SQLite, from environment setup to CI integration.

In the previously written Flutter Integration Test Basic Guide, we covered the basic setup for Flutter 2.x. This post is a practical guide for Flutter 3.x.

Differences from the Previous Guide

E2E test setup has been greatly simplified in Flutter 3.x.

ItemFlutter 2.x (Previous)Flutter 3.x (Current)
Driver filetest_driver/integration_test.dart requiredNot needed
Android setupModify build.gradle, create MainActivityTest.javaNot needed
iOS setupManually create RunnerTests target in XcodeNot needed
Run commandflutter driveflutter test integration_test

In Flutter 3.x, you just add the package to pubspec.yaml and write test files in the integration_test/ directory.

Environment Setup

Adding the Package

Add integration_test to dev_dependencies in pubspec.yaml.

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

If you use a dedicated test DB for E2E tests, register it in assets as well.

flutter:
  assets:
    - assets/data.db
    - assets/test.db # For E2E tests

Directory Structure

integration_test/
  app_test.dart              # Single entry point (runs all tests)
  helpers/
    test_app.dart            # App bootstrap
    mock_setup.dart          # Controller mocking
    db_setup.dart            # DB reset
  flows/
    learning_flow.dart       # Learning flow test
    review_flow.dart         # Quiz flow test
    search_flow.dart         # Search flow test

Important: Files in the flows/ directory should NOT end with _test.dart. flutter test integration_test automatically discovers _test.dart files and runs each as a separate process. If both app_test.dart and learning_flow_test.dart exist, each file’s main() runs independently, causing Firebase initialization, DB copying, and controller registration to execute twice. The second process then conflicts with resources already held by the first, causing a hang. Removing the _test.dart suffix excludes files from the test runner’s auto-discovery, so they only run when explicitly imported and called from app_test.dart.

Test Infrastructure

The key to applying E2E tests to a real app is controlling external dependencies. Firebase, ad SDKs, in-app purchases, etc. need to be mocked or disabled in the test environment.

SQLite DB Reset Helper

When E2E testing an app that uses SQLite, you need to reset the DB to its initial state before each run to ensure deterministic test results.

In widget tests, you can directly copy DB files from the local filesystem using File.copySync(), but E2E tests run inside the app sandbox on a real device. Therefore, you need to read assets via rootBundle and copy them to the app’s documents directory.

Also, while widget tests can open the DB directly with sqlite3’s synchronous API (sqlite3.open()), E2E tests call the production code’s DB initialization logic as-is. The production init() internally checks for DB file existence and performs migrations like creating tables or adding columns. By reusing production code, there’s no risk of missing migrations.

// integration_test/helpers/db_setup.dart
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import 'package:your_app/helpers/db_helper.dart';

Future<void> resetDatabase() async {
  final dbHelper = DBHelper();

  // 1. Close existing DB connection
  dbHelper.dispose();

  // 2. Get DB path and delete existing files (including WAL/SHM auxiliary files)
  final dbPath = await dbHelper.getDBPath();
  final basePath = p.dirname(dbPath);
  await dbHelper.deleteFileWithAuxiliary('$basePath/data.db');

  // 3. Copy test.db from assets to app documents directory as data.db
  final data = await rootBundle.load('assets/test.db');
  final dir = Directory(basePath);
  if (!dir.existsSync()) {
    await dir.create(recursive: true);
  }
  await File('$basePath/data.db').writeAsBytes(data.buffer.asUint8List());

  // 4. Initialize DB (reuses production migration logic)
  await dbHelper.init();
}

This resetDatabase() function is called inside initTestApp() in the Test App Bootstrap section below.

Controller Mocking

Since E2E tests run on a real device, controllers that call native SDKs must be replaced with mocks. For example, ad SDKs (MobileAds), notifications (FlutterLocalNotifications), and in-app purchases (InAppPurchase) can trigger permission dialogs or crashes during testing.

When using GetX, controllers often call native SDKs in onInit(). To prevent this, inherit from the controller and override onInit() to skip the SDK calls.

// integration_test/helpers/mock_setup.dart

// Mock to prevent ad SDK calls
// Original BannerController.onInit() calls MobileAds.instance.setAppMuted(true)
class _MockBannerController extends BannerController {
  _MockBannerController() : super(isRectangle: true);

  @override
  // ignore: must_call_super
  void onInit() {}
}

// Mock to prevent notification permission requests
// Original NotificationController.onInit() requests system notification permissions
class _MockNotificationController extends NotificationController {
  @override
  // ignore: must_call_super
  void onInit() {}
}

// ignore: must_call_super is a Dart analyzer comment to suppress warnings. Since onInit() has @mustCallSuper declared, not calling super triggers a warning, but we intentionally skip it in E2E tests.

Key points for controller registration:

  1. Leverage GetX’s Get.put() duplicate registration behavior. If an instance of the same type is already registered, calling Get.put() again returns the existing one without creating a new instance. So if you register mock controllers with Get.put() before the app’s Binding code (Get.put(MyController())) runs, the Binding will use the existing mock.

  2. Use premium feature flags to disable ads. If your app removes ads through in-app purchases, you can force-set the paid user state in tests so ad widgets don’t render at all. This way the app works normally without MobileAds SDK initialization. You should also replace the in-app purchase platform interface with a Fake implementation to block store calls.

  3. Pre-disable dialogs via SharedPreferences. Tutorials, app review prompts, and similar dialogs often store an “already seen” flag in SharedPreferences. Setting this flag to true in advance prevents the dialogs from showing.

SharedPreferences.setMockInitialValues({
  'tutorialShown': true,
  'reviewDialogShown': true,
});

Test App Bootstrap

Combine all the helpers above into a single initTestApp() function. This function is called once before tests run to prepare the app’s execution environment.

// integration_test/helpers/test_app.dart
Future<void> initTestApp() async {
  // 1. Initialize Firebase (required on real devices)
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // 2. Set test mode (block Firebase event sending)
  CrashlyticsHelper.setTestMode(true);
  AnalyticsHelper.setTestMode(true);
  Get.testMode = true;

  // 3. Set environment variables (replace server URL, language, app name, etc. with test values)
  _setTestEnv();

  // 4. Reset DB (copy test.db → data.db + run migrations)
  await resetDatabase();

  // 5. Register mock controllers
  await registerMockControllers();
}

Firebase is actually initialized, but test mode flags block event sending. Since debug build Firebase settings are used, production data is not affected.

Writing Tests

Single Entry Point Structure

flutter test integration_test runs each _test.dart file as a separate process. If multiple files each call initTestApp(), Firebase re-initialization, DB file conflicts, etc. cause hangs.

Solution: Use app_test.dart as the single entry point, and export individual flow tests as functions to import.

// integration_test/app_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:your_app/main.dart';

import 'flows/learning_flow.dart';
import 'flows/review_flow.dart';
import 'flows/search_flow.dart';
import 'helpers/test_app.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  setUpAll(() async {
    await initTestApp();
  });

  group('Smoke test', () {
    testWidgets('App starts successfully', (tester) async {
      await tester.pumpWidget(const MyApp());
      await tester.pumpAndSettle();
      expect(find.byType(HomeScreen), findsOneWidget);
    });
  });

  learningFlowTests();
  reviewFlowTests();
  searchFlowTests();
}

Each flow file exports only a function without main().

// integration_test/flows/learning_flow.dart
void learningFlowTests() {
  group('Learning flow', () {
    testWidgets('List → Detail screen navigation', (tester) async {
      await tester.pumpWidget(const MyApp());
      await tester.pumpAndSettle();
      // ... test code
    });
  });
}

Smoke Test

The most basic test, verifying that the app starts without crashing and the first screen renders.

testWidgets('App starts successfully', (tester) async {
  await tester.pumpWidget(const MyApp());
  await tester.pumpAndSettle();
  expect(find.byType(HomeScreen), findsOneWidget);
});

This single test verifies that Firebase initialization, DB loading, state management controller registration, and widget rendering all work correctly.

Verify screen-to-screen navigation using actual UI taps.

testWidgets('List → Detail → Sub-detail navigation', (tester) async {
  await tester.pumpWidget(const MyApp());
  await tester.pumpAndSettle();

  // Tap list item → Detail screen
  await tester.tap(find.text('Item 1'));
  await tester.pumpAndSettle();
  expect(find.byType(DetailScreen), findsOneWidget);

  // Tap sub-item → Sub-detail screen
  await tester.tap(find.byType(SubItem).first);
  await tester.pumpAndSettle();
  expect(find.text('Sub Detail'), findsOneWidget);
});

Instead of navigating programmatically with Get.toNamed(), use actual UI taps. This naturally verifies Binding, Arguments passing, and Controller initialization.

When testing expand/collapse widgets like ExpansionTile, see Tap Position Issue with Expanded Widgets.

Search Flow Test

Verify text input and asynchronous search results.

testWidgets('Search bar → Enter keyword → Show results', (tester) async {
  await tester.pumpWidget(const MyApp());
  await tester.pumpAndSettle();

  // Tap search bar → Navigate to search screen
  await tester.tap(find.byType(SearchBar));
  await tester.pumpAndSettle();

  // Enter keyword
  await tester.enterText(find.byType(TextField), 'search term');
  await tester.pumpAndSettle();

  // Verify search results
  expect(find.byType(SearchResultItem), findsWidgets);
});

Since E2E tests run in real time, debounce timers expire naturally. pumpAndSettle() then waits until the UI stabilizes. No separate waiting logic is needed.

Running Tests

This guide covers Android emulators. iOS simulators can also run tests with flutter test integration_test in the same way.

Local Execution

Run with the following command while an emulator is connected:

flutter test integration_test/app_test.dart -d <device-id>

Or write a Dart script in the bin/ directory to automate emulator detection, auto-start, and previous app force-stop.

// bin/e2e_test.dart
import 'dart:io';

Future<void> main(List<String> args) async {
  // Auto-detect emulator (parse flutter devices --machine output)
  String? deviceId = await _findRunningEmulator();
  if (deviceId == null) {
    // Auto-start emulator (use flutter emulators --launch)
    deviceId = await _startAndroidEmulator();
  }

  // Force-stop previous app process (prevent hang)
  if (deviceId != null) {
    await Process.run('adb', [
      '-s', deviceId, 'shell', 'am', 'force-stop', 'com.example.app',
    ]);
  }

  // Run E2E tests
  final testProcess = await Process.start('flutter', [
    'test', 'integration_test', '-d', deviceId!,
    ...args,
  ], mode: ProcessStartMode.inheritStdio);

  exit(await testProcess.exitCode);
}

// _findRunningEmulator, _startAndroidEmulator implementations
// consist of parsing flutter devices --machine JSON output,
// calling flutter emulators --launch, etc.
dart run bin/e2e_test.dart

CI (GitHub Actions)

Configure a workflow triggered by workflow_dispatch (manual trigger) or PR labels. Since E2E tests take a long time and are costly, we recommend triggering manually only when needed rather than running automatically on every PR.

name: E2E Test
on:
  workflow_dispatch:
  pull_request:
    types: [labeled]
jobs:
  e2e_test:
    name: E2E Test
    if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'e2e-test'
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'
      - name: Install dependencies
        uses: ./.github/actions/install_dependencies
      - 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: Accept Android SDK licenses
        run: yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses || true
      - name: Cache Gradle
        uses: actions/cache@v4
        with:
          path: |
            ~/.gradle/caches
            ~/.gradle/wrapper
          key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
          restore-keys: gradle-${{ runner.os }}-
      - name: Pre-build APK
        run: flutter build apk --debug
      - name: Run E2E tests
        uses: reactivecircus/android-emulator-runner@v2
        with:
          api-level: 34
          target: google_apis
          arch: x86_64
          emulator-options: >-
            -no-window -gpu swiftshader_indirect -noaudio
            -no-boot-anim -camera-back none
          disable-animations: true
          emulator-boot-timeout: 300
          script: flutter test integration_test/app_test.dart

Key points:

  • Enable KVM: Required for emulator hardware acceleration on Linux runners.
  • Accept SDK licenses: The license prompt when first installing system images can cause CI to hang. yes | auto-accepts.
  • Gradle cache: The first Gradle build can take over 35 minutes on CI (2 vCPU). Caching dramatically reduces time from the second run.
  • Pre-build APK: Running Gradle build and emulator simultaneously can exhaust resources (CPU/memory). Build the APK first so the emulator step only runs tests.
  • target: google_apis: Apps using Firebase need system images with Google Play Services.

Troubleshooting

pumpAndSettle Hangs

pumpAndSettle() waits until all frames and animations stop. It will wait indefinitely if there are infinite animations (like CircularProgressIndicator) or active Timer/Streams continuously triggering frames.

Solution: When you need to wait for async data loading, use a polling loop instead of pumpAndSettle().

// Wait up to 10 seconds for a specific widget to appear
for (var i = 0; i < 20; i++) {
  if (find.byType(AnswerButton).evaluate().isNotEmpty) break;
  await tester.pump(const Duration(milliseconds: 500));
}

Dialogs Blocking Taps

pumpAndSettle() also processes async timers like Future.delayed. So if there’s code like “show dialog after 300ms”, the dialog is already covering the screen by the time pumpAndSettle() completes.

To check if a dialog is the cause, look for RenderAbsorbPointer in the test failure logs. This widget is the dialog’s background overlay and blocks all taps on widgets behind it.

Solution: Pre-disabling dialogs via SharedPreferences flags is the most stable approach. If that’s not possible, dismiss the dialog before proceeding.

// Dismiss dialog if displayed
final closeButton = find.text('Close');
if (closeButton.evaluate().isNotEmpty) {
  await tester.tap(closeButton);
  await tester.pumpAndSettle();
}

Tap Position Issue with Expanded Widgets

tester.tap() taps the center of a widget. When an ExpansionTile is collapsed, the center is in the header area, but when expanded, the widget’s height increases and the center moves to the content area. Tapping the content area doesn’t collapse the ExpansionTile.

Solution: For collapse actions, directly tap the collapse icon like Icons.expand_less.

// Expand — center is in header area when collapsed, works correctly
await tester.tap(find.byType(MyExpansionWidget).first);

// Collapse — directly tap the icon when expanded
await tester.tap(find.byIcon(Icons.expand_less).first);

Hang When Re-running on Emulator

If you run E2E tests again without closing the app on the emulator, the app install/start step may hang. This is because the previous app process is still running on the device.

Solution: Force-stop the app before running tests.

adb -s emulator-5554 shell am force-stop com.example.app

This can be automated in the Local Execution script above.

Summary

Setting up E2E tests in Flutter 3.x has been greatly simplified at the framework level, but controlling external dependencies is the key when applying to real projects.

Key takeaways:

  • Basic setup is complete with just adding the integration_test package
  • Mock Firebase, ads, IAP, etc. in a test-specific entry point
  • Run all tests from a single app_test.dart to prevent duplicate initialization
  • Understand pumpAndSettle() behavior and handle dialogs/animations
  • Gradle cache and Pre-build are essential for CI

Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!

App promotion

You can use the applications that are created by this blog writer Deku.
Deku created the applications with Flutter.

If you have interested, please try to download them for free.



SHARE
Twitter Facebook RSS