By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.
Testing in Flutter follows a pyramid: lots of fast unit tests, a moderate number of widget tests, and a few slow integration tests. The mistake most teams make is inverting the pyramid — writing few unit tests and relying on slow integration tests that are brittle, flaky, and give feedback minutes after a change.
At iStoria, our 50+ module codebase has thousands of tests running in CI on every pull request. The strategy is simple: domain logic is unit-tested to the extreme, presentation is widget-tested with mocked dependencies, and only critical user journeys get integration tests. This keeps the suite fast (under 3 minutes) and trustworthy (no flaky tests).
This guide covers what to test at each layer, the patterns we use, and how to keep a large test suite maintainable.
The test pyramid has three levels, from most to fewest:
1. Unit tests (70%) — test individual classes in isolation. Domain entities, use cases, repositories (with mocked data sources), mappers, and BLoC/Cubit state transitions. These are pure Dart tests that run in milliseconds.
2. Widget tests (20%) — test individual widgets or small widget trees with mocked dependencies. You pump a widget, interact with it (tap, scroll, enter text), and assert on what it renders. These run in seconds.
3. Integration tests (10%) — test full user flows end-to-end on a simulated device. The app runs with real (or near-real) dependencies and the test drives it like a user. These are slow (minutes) and should be reserved for critical journeys (login, checkout, lesson completion).
The pyramid ratio is not arbitrary. Unit tests are fast, deterministic, and pinpoint exactly what broke. Integration tests are slow, can be flaky, and when they fail you do not know which layer broke. Invest in the base of the pyramid.
The domain layer is the easiest to test because it has no dependencies. Entities, value objects, use cases — all pure Dart, no mocking needed.
For use cases that depend on repository contracts, pass mock repositories (via mocktail or handwritten fakes). The test verifies that the use case calls the right methods, in the right order, with the right arguments, and maps the result correctly.
test('GetLessons returns Right(lessons) when repository succeeds', () async {
when(() => mockRepo.getLessons('c1'))
.thenAnswer((_) async => Right([testLesson]));
final result = await usecase('c1');
expect(result.isRight(), true);
});
These tests are the foundation. They run in under 1ms each and give you confidence that the business logic is correct regardless of the UI or data layer.
Repository tests verify the mapping from data-source exceptions to domain Failures. The repository is tested with mocked data sources — you simulate a server error, a cache miss, an offline state, and verify the repository returns the correct Either<Failure, T>.
This is where the Either<Failure, T> pattern pays off. Each test asserts on the exact Failure type returned, which documents the repository's error contract:
test('returns OfflineFailure when network is disconnected', () async {
when(() => networkInfo.isConnected).thenAnswer((_) async => false);
final result = await repository.getLessons('c1');
expect(result.getLeft().toOption().toNullable(), isA<OfflineFailure>());
});
BLoC tests verify state sequences — given an event, the BLoC emits the expected states in order. The bloc_test package makes this declarative:
blocTest<LessonBloc, LessonState>(
'emits [Loading, Loaded] on success',
build: () {
when(() => repo.getLessons(any()))
.thenAnswer((_) async => Right([testLesson]));
return LessonBloc(repo);
},
act: (b) => b.add(LessonLoadRequested('c1')),
expect: () => [isA<LessonLoading>(), isA<LessonLoaded>()],
);
Every BLoC should have a test for every event handler, covering success, failure, and edge cases (empty list, pagination, concurrent events). These tests are fast and catch the majority of state-management bugs before they reach a device.
Widget tests verify that a widget renders correctly and responds to interaction. The widget is pumped with mocked BLoCs (via BlocProvider with a mock or a seeded BLoC):
testWidgets('renders lesson titles', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: BlocProvider.value(
value: seededLessonBloc(LessonLoaded(lessons: [testLesson])),
child: const LessonPage(),
),
),
);
expect(find.text('Introduction to Flutter'), findsOneWidget);
});
Widget tests should test behavior, not implementation: "when I tap this button, this widget appears" — not "when I tap this button, this method is called." Implementation-coupled tests break on refactors and provide false confidence.
Golden tests (also called snapshot tests) capture a rendered widget as an image and compare future renders against it. They are excellent for catching unintended UI changes — a padding change, a color regression, a layout shift.
testWidgets('lesson card matches golden', (tester) async {
await tester.pumpWidget(wrapWithMaterial(LessonCard(lesson: testLesson)));
await expectLater(find.byType(LessonCard), matchesGoldenFile('lesson_card.png'));
});
At iStoria, we use golden tests for reusable components (cards, buttons, list items) but not for full pages (too brittle). When the design changes intentionally, regenerate the goldens with --update-goldens.
Integration tests (integration_test/ package) run the real app on a device or simulator and drive it like a user. They are reserved for critical user journeys:
Keep integration tests few and focused. Each one adds minutes to CI. Ten integration tests covering the critical paths are more valuable than fifty covering every edge case (those belong in unit/widget tests).
In CI, split the test runs:
1. Unit + Widget tests — run on every pull request. Should complete in under 3 minutes. 2. Integration tests — run on merge to main or nightly. These are slower and do not need to block every PR. 3. Golden tests — run on every PR but do not block (report differences for review). Block only on main.
Use --coverage and track coverage trends, but do not obsess over a coverage number. 80% coverage with meaningful tests is better than 100% coverage with trivial assertion-free tests. The goal is confidence, not a metric.
test/ # mirrors lib/ structure
├── core/
│ ├── error/
│ │ └── failures_test.dart
│ └── utils/
│ └── input_validator_test.dart
├── features/
│ └── lesson/
│ ├── domain/
│ │ ├── usecases/
│ │ │ └── get_lessons_test.dart
│ │ └── entities/
│ │ └── lesson_test.dart
│ ├── data/
│ │ ├── repositories/
│ │ │ └── lesson_repository_impl_test.dart
│ │ └── models/
│ │ └── lesson_model_test.dart # mapper tests
│ └── presentation/
│ ├── bloc/
│ │ └── lesson_bloc_test.dart # bloc_test
│ └── widgets/
│ ├── lesson_card_test.dart # widget test
│ └── lesson_card_test.png # golden image
└── helpers/
├── test_lesson.dart # shared fixture factory
├── mock_repository.dart # MockTail setup
└── widget_test_helpers.dart # pumpWidget wrappers
integration_test/
├── auth_flow_test.dart
├── lesson_completion_test.dart
└── offline_sync_test.dart
// test/helpers/mock_repository.dart — shared mock setup
import 'package:mocktail/mocktail.dart';
import 'package:flutter_test/flutter_test.dart';
class MockLessonRepository extends Mock implements LessonRepository {}
class MockNetworkInfo extends Mock implements NetworkInfo {}
MockLessonRepository setupMockLessonRepository() {
final repo = MockLessonRepository();
registerFallbackValue('test-course-id');
return repo;
}
// test/features/lesson/data/repositories/lesson_repository_impl_test.dart
class MockRemote extends Mock implements LessonRemoteDatasource {}
class MockLocal extends Mock implements LessonLocalDatasource {}
void main() {
late MockRemote remote;
late MockLocal local;
late MockNetworkInfo networkInfo;
late LessonRepositoryImpl repository;
setUp(() {
remote = MockRemote();
local = MockLocal();
networkInfo = MockNetworkInfo();
repository = LessonRepositoryImpl(
remoteDatasource: remote,
localDatasource: local,
networkInfo: networkInfo,
);
});
group('getLessons', () {
test('returns Right(lessons) when remote succeeds', () async {
when(() => networkInfo.isConnected).thenAnswer((_) async => true);
when(() => remote.fetchLessons('c1'))
.thenAnswer((_) async => [lessonModelFixture()]);
final result = await repository.getLessons('c1');
expect(result.isRight(), true);
verify(() => local.cacheLessons('c1', any())).called(1);
});
test('returns OfflineFailure when disconnected and cache is empty',
() async {
when(() => networkInfo.isConnected).thenAnswer((_) async => false);
when(() => local.getCachedLessons('c1'))
.thenThrow(CacheException('empty'));
final result = await repository.getLessons('c1');
expect(result.getLeft().toOption().toNullable(), isA<OfflineFailure>());
});
test('falls back to cache when server throws ServerException', () async {
when(() => networkInfo.isConnected).thenAnswer((_) async => true);
when(() => remote.fetchLessons('c1'))
.thenThrow(ServerException('500', 500));
when(() => local.getCachedLessons('c1'))
.thenAnswer((_) async => [lessonModelFixture()]);
final result = await repository.getLessons('c1');
expect(result.isRight(), true);
});
});
}
// test/features/lesson/presentation/bloc/lesson_bloc_test.dart
void main() {
late MockLessonRepository repository;
late LessonBloc bloc;
setUp(() {
repository = MockLessonRepository();
bloc = LessonBloc(repository);
});
blocTest<LessonBloc, LessonState>(
'emits [Loading, Loaded] on successful load',
build: () {
when(() => repository.getLessons(any()))
.thenAnswer((_) async => Right([lessonFixture()]));
return bloc;
},
act: (b) => b.add(LessonLoadRequested('c1')),
wait: const Duration(milliseconds: 100),
expect: () => [
isA<LessonLoading>(),
isA<LessonLoaded>()
.having((s) => s.lessons.length, 'lesson count', 1),
],
);
blocTest<LessonBloc, LessonState>(
'emits [Loading, Error] on failure',
build: () {
when(() => repository.getLessons(any()))
.thenAnswer((_) async => const Left(ServerFailure('down')));
return bloc;
},
act: (b) => b.add(LessonLoadRequested('c1')),
wait: const Duration(milliseconds: 100),
expect: () => [
isA<LessonLoading>(),
isA<LessonError>().having((s) => s.message, 'error', 'down'),
],
);
}
// integration_test/lesson_completion_test.dart — full device test
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('user can complete a lesson end-to-end', (tester) async {
app.main(); // launches the real app
await tester.pumpAndSettle();
// Navigate to a course
await tester.tap(find.text('Flutter Basics'));
await tester.pumpAndSettle();
// Tap a lesson
await tester.tap(find.text('Introduction'));
await tester.pumpAndSettle();
// Mark complete
await tester.tap(find.byKey(const Key('mark-complete-btn')));
await tester.pumpAndSettle();
// Verify the completion indicator appears
expect(find.byIcon(Icons.check_circle), findsOneWidget);
});
}
Few. Integration tests are slow and flaky. Reserve them for critical user journeys: login, the core value action (e.g., completing a lesson), payment, and the offline-to-online transition. Everything else should be covered by unit and widget tests. Ten focused integration tests are worth more than fifty that cover edge cases.
Mock them. A real BLoC fires network calls and has async state transitions that make the test non-deterministic. Use BlocProvider.value with a seeded or mocked BLoC so the widget test is deterministic: given this state, does the widget render correctly? The BLoC itself is tested separately in bloc_test cases.
Use golden tests for small, reusable components (cards, buttons) rather than full pages. Run them on the same platform and font rendering in CI as locally. When the design changes intentionally, regenerate with --update-goldens and review the diff in the PR. Do not block PRs on golden failures — report them for review and block only on main.
Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →
Book a 20-minute call · Download the CV (PDF) · See how I work