mocktailfor null-safe mocking,integration_testfor device-level validation, andflutter_testfor widget/unit execution. Avoid legacyflutter_driver`.
// test/helpers/test_binding.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
void setupTestBinding() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// Override platform dispatcher for consistent timing
debugDefaultTargetPlatformOverride = TargetPlatform.android;
}
Step 3: Implement Unit Tests with Deterministic Mocks
Isolate business logic from UI and platform dependencies. Use mocktail to generate strict mocks with verified call counts.
// test/repositories/auth_repository_test.dart
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
import 'package:my_app/repositories/auth_repository.dart';
import 'package:my_app/services/api_service.dart';
class MockApiService extends Mock implements ApiService {}
void main() {
late AuthRepository repository;
late MockApiService mockApi;
setUp(() {
mockApi = MockApiService();
repository = AuthRepository(apiService: mockApi);
});
test('login returns user when credentials are valid', () async {
const email = 'dev@codcompass.io';
const token = 'test-token';
when(() => mockApi.authenticate(email, any())).thenAnswer(
(_) async => {'token': token, 'expiresIn': 3600}
);
final result = await repository.login(email, 'password');
expect(result.token, token);
verify(() => mockApi.authenticate(email, 'password')).called(1);
verifyNoMoreInteractions(mockApi);
});
}
Widget tests should validate state transitions, user interactions, and error boundaries. Use explicit pump() calls to control async rendering instead of relying on pumpAndSettle() for deterministic timing.
// test/widgets/login_form_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/widgets/login_form.dart';
void main() {
testWidgets('shows validation error on empty submit', (tester) async {
await tester.pumpWidget(
MaterialApp(home: LoginForm(onSubmit: (_) {})),
);
await tester.tap(find.byType(ElevatedButton));
await tester.pump(); // Advance one frame, do not settle
expect(find.text('Email is required'), findsOneWidget);
expect(find.text('Password is required'), findsOneWidget);
});
testWidgets('calls onSubmit with valid credentials', (tester) async {
String? capturedEmail;
await tester.pumpWidget(
MaterialApp(
home: LoginForm(
onSubmit: (email) => capturedEmail = email,
),
),
);
await tester.enterText(find.byType(TextField).first, 'dev@codcompass.io');
await tester.enterText(find.byType(TextField).last, 'secure123');
await tester.tap(find.byType(ElevatedButton));
await tester.pump();
expect(capturedEmail, 'dev@codcompass.io');
});
}
Step 5: Isolate Integration Tests to Critical Paths
Integration tests run on real devices or emulators. Limit them to flows that cross platform boundaries or require persistent state. Use WidgetTester from integration_test for consistent API.
// integration_test/app_flow_test.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();
testWidgets('complete onboarding flow', (tester) async {
app.main();
await tester.pumpAndSettle();
await tester.tap(find.text('Get Started'));
await tester.pumpAndSettle();
await tester.enterText(find.byHintText('Username'), 'tester');
await tester.tap(find.text('Create Account'));
await tester.pumpAndSettle();
expect(find.text('Dashboard'), findsOneWidget);
});
}
Architecture Decisions & Rationale
mocktail over mockito: Null-safe, no build runners required, enforces strict verification.
integration_test over flutter_driver: Officially maintained, shares test API with flutter_test, supports golden testing on device, and integrates with flutter test --integration-test.
- Explicit
pump() over pumpAndSettle(): Prevents race conditions in async state updates. pumpAndSettle() waits for all animations and timers, masking timing bugs that surface in production.
- Golden tests as snapshots, not contracts: Use
golden_toolkit for visual regression but pair with behavioral widget tests. Goldens break on font rendering changes and device DPI shifts; they should never validate interaction logic.
Pitfall Guide
1. Over-Testing Implementation Details
Mistake: Asserting on private methods, internal state variables, or widget tree depth.
Impact: Tests break on harmless refactors, inflating maintenance cost.
Best Practice: Test observable behavior. If a UI element responds to user input and produces expected output, the internal state structure is irrelevant to the test contract.
2. Misusing pumpAndSettle()
Mistake: Defaulting to pumpAndSettle() for every async operation.
Impact: Masks timing bugs, increases test duration by 3-5x, and causes false positives when animations never complete.
Best Practice: Use tester.pump(Duration(milliseconds: X)) for controlled advancement. Reserve pumpAndSettle() for integration tests where full rendering completion is required.
3. Golden Test Brittleness
Mistake: Treating pixel-perfect matches as functional verification.
Impact: CI fails on OS font updates, locale changes, or CI runner DPI differences.
Best Practice: Use goldens only for visual regression on critical screens. Run them in isolated CI jobs. Pair with behavioral widget tests that validate layout constraints, not pixel coordinates.
4. Flaky Integration Tests
Mistake: Running integration tests without device state isolation or network mocking.
Impact: Tests fail intermittently due to background sync, push notifications, or platform channel timing.
Best Practice: Reset app state between tests using tester.binding.window.clearMetrics(). Mock platform channels with MethodChannel.setMockMethodCallHandler(). Run integration tests on emulators with fixed locale and timezone.
5. Violating Test Isolation
Mistake: Sharing global state, singletons, or cached repositories across test files.
Impact: Tests pass locally but fail in CI due to execution order dependency.
Best Practice: Instantiate fresh dependencies in setUp(). Use dependency injection or service locators that reset per test. Never mutate global main() state.
6. Overusing find.byType
Mistake: Relying on widget types for interaction when multiple instances exist.
Impact: find.byType(TextFormField) returns multiple widgets, causing tap() to throw or interact with the wrong instance.
Best Practice: Use find.byWidgetPredicate(), find.byKey(), or semantic labels. Add Key objects to widgets that require deterministic interaction.
7. Skipping Coverage Analysis
Mistake: Assuming high test count equals high coverage.
Impact: Critical branches remain untested while trivial UI tests inflate metrics.
Best Practice: Run flutter test --coverage and analyze lcov.info with genhtml. Enforce minimum coverage thresholds (e.g., 80% for business logic, 60% for UI) in CI. Exclude generated files and routing configuration.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup MVP | Unit-heavy (80%) + minimal widget tests | Fast feedback, low CI cost, validates core logic | Low infrastructure cost, faster iteration |
| Large team (10+ devs) | Balanced Hybrid with test sharding | Prevents pipeline bottlenecks, enforces consistency across packages | Moderate CI spend, higher developer velocity |
| High-UI app (e-commerce, design tools) | Widget + golden tests (30%) + strict integration | Validates complex layouts, animations, and visual regression | Higher test maintenance, reduced UI defect escape |
| CI-constrained environment | Unit tests + cached golden snapshots | Minimizes compute time, avoids emulator provisioning | Low cloud cost, delayed visual feedback |
Configuration Template
# pubspec.yaml
dev_dependencies:
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
mocktail: ^1.0.3
golden_toolkit: ^0.15.0
coverage: ^1.6.3
test: ^1.24.0
# analysis_options.yaml
linter:
rules:
avoid_print: true
prefer_const_constructors: true
test_types_in_equals: true
unnecessary_test_assertions: true
# .github/workflows/flutter_test.yml
name: Flutter Test Suite
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.19.0'
- run: flutter pub get
- run: flutter test --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: coverage/lcov.info
- name: Run integration tests
if: github.event_name == 'push'
run: flutter test integration_test/
Quick Start Guide
- Initialize test dependencies: Run
flutter pub add dev:mocktail dev:golden_toolkit dev:coverage in your project root.
- Create test directory structure:
mkdir -p test/{unit,widget,integration} helpers fixtures.
- Write first unit test: Create
test/unit/auth_repository_test.dart using mocktail and test() assertions. Run flutter test test/unit/.
- Verify widget isolation: Add a widget test with explicit
pump() calls. Run flutter test test/widget/ and confirm no pumpAndSettle() warnings.
- Execute full suite: Run
flutter test --coverage. Review coverage/lcov.info with genhtml coverage/lcov.info -o coverage/html and open index.html in a browser.