← Flutter Reference

Flutter Testing Strategy: Unit, Widget & Integration Tests

Reliability · Intermediate

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

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.

Layer 1: Unit Testing the Domain

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.

Layer 2: Unit Testing Repositories

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>());
});

Layer 3: BLoC/Cubit Testing

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.

Layer 4: Widget Testing

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.

Layer 5: Golden Tests

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.

Layer 6: Integration Tests

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:

  • Login and authentication flow
  • Completing a lesson (core user value)
  • Offline-to-online sync transition
  • Payment / subscription flow

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).

CI Strategy

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.

Recommended folder structure

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

Code example

// 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);
  });
}

Implementation checklist

  • Mirror the test/ directory structure to lib/ so tests are easy to find next to the code they verify.
  • Write unit tests for every domain entity, use case, and repository method — these run in milliseconds and form the base of the pyramid.
  • Write bloc_test cases for every BLoC event handler, covering success, failure, and edge-case state transitions.
  • Write widget tests that verify behavior (tap → widget appears), not implementation (tap → method called).
  • Add golden tests for reusable UI components to catch unintended visual regressions.
  • Reserve integration tests for critical user journeys only (login, core value action, sync transition) — keep them under 10.
  • Split CI runs: unit + widget tests on every PR (under 3 minutes), integration tests on main merge or nightly.

Common mistakes

  • Writing integration tests for things that should be unit tests, making the suite slow and flaky.
  • Testing widget implementation details (method calls) instead of behavior (visible output), causing tests to break on every refactor.
  • Not mocking BLoCs in widget tests, causing the real BLoC to fire network calls and making tests non-deterministic.
  • Skipping repository tests entirely because 'they just forward calls', missing the critical exception-to-Failure mapping logic.
  • Treating coverage percentage as the goal instead of test quality — 80% with meaningful assertions beats 100% with trivial checks.

Related definitions

Related reading

FAQ

How many integration tests should I write?

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.

Should I mock BLoCs in widget tests or use real ones?

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.

How do I keep golden tests from being flaky?

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