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: flutterCreate 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:
- Disable analytics/crash reporting
- Use a test backend or local mock server
- 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 transitionWorking 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.myappScrolling 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_UDIDTips 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.