Table of Contents
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.
| Item | Flutter 2.x (Previous) | Flutter 3.x (Current) |
|---|---|---|
| Driver file | test_driver/integration_test.dart required | Not needed |
| Android setup | Modify build.gradle, create MainActivityTest.java | Not needed |
| iOS setup | Manually create RunnerTests target in Xcode | Not needed |
| Run command | flutter drive | flutter 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:
-
Leverage GetX’s
Get.put()duplicate registration behavior. If an instance of the same type is already registered, callingGet.put()again returns the existing one without creating a new instance. So if you register mock controllers withGet.put()before the app’s Binding code (Get.put(MyController())) runs, the Binding will use the existing mock. -
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
MobileAdsSDK initialization. You should also replace the in-app purchase platform interface with a Fake implementation to block store calls. -
Pre-disable dialogs via SharedPreferences. Tutorials, app review prompts, and similar dialogs often store an “already seen” flag in SharedPreferences. Setting this flag to
truein 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.
Navigation Flow Test
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_testpackage - Mock Firebase, ads, IAP, etc. in a test-specific entry point
- Run all tests from a single
app_test.dartto 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
Deku.Deku created the applications with Flutter.If you have interested, please try to download them for free.