Flutter Testing That Actually Works: From Unit to Integration
Most Flutter testing tutorials show you how to test a counter app. Real apps have async providers, database dependencies, theme extensions, and complex state machines. Here's how to test those.
The Testing Pyramid for Flutter
Build tests bottom-up. Each layer depends on the one below:
| Phase | What to Test | How |
|---|---|---|
| Domain entities | Computed properties, copyWith, edge cases | Pure Dart unit tests, no mocks |
| Data sources | API converters, mappers, DB operations | Unit tests with mocked SDKs |
| Services | Business logic, validation, calculations | Unit tests with mocked repositories |
| Controllers | State transitions, action methods | ProviderContainer with mocked services |
| Pages | UI rendering, user interactions, states | Widget tests with ProviderScope overrides |
Infrastructure First: mocks.dart and test_data.dart
Before writing a single test, set up two files that every test will use.
Centralized Mocks
// test/src/mocks.dart
import 'package:mocktail/mocktail.dart';
class MockDisciplineRepository extends Mock implements DisciplineRepository {}
class MockDisciplineService extends Mock implements DisciplineService {}
class MockJournalService extends Mock implements JournalService {}
class MockAnalyticsService extends Mock implements AnalyticsService {}
// State listener for verifying state transitions
class Listener<T> extends Mock {
void call(T? previous, T next);
}
One file, all mocks. Never scatter mock classes across test files.
Factory Functions for Test Data
// test/src/test_data.dart
UserProgress makeProgress({
int currentStreak = 0,
DateTime? startDate,
Map<String, bool>? completedTasks,
}) {
return UserProgress(
currentStreak: currentStreak,
startDate: startDate ?? DateTime.now(),
completedTasks: completedTasks ?? {},
);
}
JournalEntry makeJournalEntry({int dayNumber = 1, String mood = 'good'}) {
return JournalEntry(dayNumber: dayNumber, mood: mood, note: '');
}
Every test entity has a make*() factory with sensible defaults. Override only what the test cares about. Never construct entities inline.
Service Tests: The Core
Service tests are the most valuable. They test your business logic with mocked dependencies:
void main() {
setUpAll(() {
registerFallbackValue(makeProgress()); // Required for any()
});
late MockDisciplineRepository mockRepository;
late DisciplineService service;
setUp(() {
mockRepository = MockDisciplineRepository();
service = DisciplineService(mockRepository);
});
group('calculateCurrentDay', () {
test('returns 1 on start date', () {
final startDate = DateTime.now();
final progress = makeProgress(startDate: startDate);
expect(service.calculateCurrentDay(progress), 1);
});
test('returns 5 after 4 days', () {
final startDate = DateTime.now().subtract(const Duration(days: 4));
final progress = makeProgress(startDate: startDate);
expect(service.calculateCurrentDay(progress), 5);
});
});
group('toggleTask', () {
test('saves progress with updated task', () async {
when(() => mockRepository.saveProgress(any()))
.thenAnswer((_) async {});
await service.toggleTaskCompletion(1, 0);
verify(() => mockRepository.saveProgress(any())).called(1);
});
});
}
Three rules for service tests:
setUpAllwithregisterFallbackValuefor every custom type used withany()setUpcreates fresh mocks for each test- Date-relative tests: always use
DateTime.now().subtract()- never hardcode dates
Controller Tests: State Transitions
Controllers use ProviderContainer with overrides:
void main() {
late MockJournalService mockService;
setUp(() {
mockService = MockJournalService();
});
ProviderContainer makeContainer() {
final container = ProviderContainer(
overrides: [
journalServiceProvider.overrideWithValue(mockService),
],
);
addTearDown(container.dispose); // CRITICAL - never skip
return container;
}
test('save calls service and updates state', () async {
when(() => mockService.saveEntry(any(), any(), any()))
.thenAnswer((_) async {});
when(() => mockService.getEntry(1))
.thenAnswer((_) async => makeJournalEntry());
final container = makeContainer();
await container.read(journalEntryControllerProvider(1).future);
await container.read(
journalEntryControllerProvider(1).notifier,
).save('content', 'mood');
verify(() => mockService.saveEntry(1, 'content', 'mood')).called(1);
});
}
The makeContainer() pattern with addTearDown(container.dispose) prevents provider leaks across tests.
Widget Tests: The Tricky Part
Widget tests require theme extensions and provider overrides:
testWidgets('TodayContent shows tasks section', (tester) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
await tester.pumpWidget(
ProviderScope(
overrides: [
sharedPreferencesProvider.overrideWith((_) => Future.value(prefs)),
isPremiumProvider.overrideWith((_) => Future.value(false)),
disciplineServiceProvider.overrideWithValue(FakeDisciplineService()),
],
child: MaterialApp(
theme: ThemeData(extensions: [AppColors.dark()]),
home: const Scaffold(body: TodayContent(state: testState)),
),
),
);
await tester.pump(); // Let async providers resolve
expect(find.text("Today's Focus"), findsOneWidget);
});
Common gotchas:
SharedPreferences.setMockInitialValues({})must come beforegetInstance()- Theme extensions are required if your widgets use
context.colors.* await tester.pump()afterpumpWidgetto resolve async providers
The Robot Pattern: No Duplicate Finders
Robots encapsulate all find.byXxx calls so test files read like user stories:
class TodayRobot {
TodayRobot(this.tester);
final WidgetTester tester;
Future<void> tapTask(int index) async {
final finder = find.byType(TaskTile).at(index);
await tester.tap(finder);
await tester.pumpAndSettle();
}
void expectTodaysFocus() {
expect(find.text("Today's Focus"), findsOneWidget);
}
void expectTaskCompleted(int index) {
final tile = tester.widget<TaskTile>(find.byType(TaskTile).at(index));
expect(tile.isCompleted, isTrue);
}
}
// Usage in tests:
testWidgets('toggle task marks it complete', (tester) async {
final r = Robot(tester);
await r.pumpApp();
r.today.expectTodaysFocus();
r.today.expectTaskCount(3);
await r.today.tapTask(0);
r.today.expectTaskCompleted(0);
});
Write the robot once, reuse it for widget tests, integration tests, AND golden tests. Zero finder duplication.
Timer-Based Tests with fake_async
Controllers that use Timer or Future.delayed need controlled time:
import 'package:fake_async/fake_async.dart';
test('auto-clears celebration after 3 seconds', () {
fakeAsync((async) {
final container = ProviderContainer();
addTearDown(container.dispose);
final controller = container.read(celebrationControllerProvider.notifier);
controller.celebrate(CelebrationEvent.dayComplete);
expect(container.read(celebrationControllerProvider), isNotNull);
async.elapse(const Duration(seconds: 3));
expect(container.read(celebrationControllerProvider), isNull);
});
});
Mock vs Fake: When to Use Which
| Type | When | Example |
|---|---|---|
| Mock (mocktail) | Unit tests - precise control with when/verify | MockDisciplineService |
| Fake (implements) | Controller/widget tests - realistic behavior | FakeDisciplineService |
Mocks are for verifying that something was called. Fakes are for providing realistic behavior without the real dependency.
Error Handling Tests
Test that your sealed exception hierarchy works end-to-end:
test('PlanLoadException has correct metadata', () {
final e = PlanLoadException(details: 'file not found');
expect(e.severity, ErrorSeverity.error);
expect(e.category, ErrorCategory.storage);
expect(e.code, 'plan-load-failed');
expect(e, isA<AppException>());
});
testWidgets('shows dialog for AppException', (tester) async {
await tester.pumpWidget(MaterialApp(
home: ErrorDialogTrigger(
asyncValue: AsyncValue.error(PlanLoadException(), StackTrace.current),
),
));
await tester.pumpAndSettle();
expect(find.text('Plan Unavailable'), findsOneWidget);
});
testWidgets('silently dismisses PurchaseCancelledException', (tester) async {
// ... same setup
expect(find.byType(AlertDialog), findsNothing);
});
The Gotcha Reference Card
| Problem | Solution |
|---|---|
MissingStubError on any() | Add registerFallbackValue in setUpAll |
| Provider leaks between tests | Add addTearDown(container.dispose) |
| Widget needs theme | Add ThemeData(extensions: [...]) |
| Async providers not resolving | Add await tester.pump() after pumpWidget |
| Date tests break next day | Use DateTime.now().subtract(), never hardcode |
| Dialog error during build | Use addPostFrameCallback to defer dialog |
Start Here
- Create
mocks.dartandtest_data.dart - Write unit tests for your most important service
- Add controller tests for your main state provider
- Add widget tests for your most complex screen
- Introduce the Robot pattern when you have 3+ widget tests
Testing isn't about 100% coverage. It's about testing the code that breaks. Business logic in services, state transitions in controllers, and user flows in widgets. Test those well, and your app will be remarkably stable.



