Skip to content
AB

Testing Flutter BLoCs Beyond the Happy Path

Test overlapping requests, retries, pagination, streams, timers, cancellation, and disposal with bloc_test, mocktail, and fake_async.

Ashish BhakhandPublished

A test that verifies loading followed by success proves only the easiest path. Production bugs often live between events: two requests overlap, a timer fires after disposal, pagination returns duplicates, or a retry keeps running forever.

Packages such as bloc_test, mocktail, and fake_async help, but the important step is choosing scenarios that stress time and ordering.

Begin with the visible contract#

Code
blocTest<CoursesBloc, CoursesState>(
  'emits loading and success when courses load',
  build: () {
    when(() => repository.getCourses()).thenAnswer(
      (_) async => [course],
    );
    return CoursesBloc(repository);
  },
  act: (bloc) => bloc.add(const CoursesRequested()),
  expect: () => [
    const CoursesState(status: CoursesStatus.loading),
    CoursesState(
      status: CoursesStatus.success,
      courses: [course],
    ),
  ],
);

This gives a baseline. Then test what can interrupt the sequence.

Control completion order#

Completers let the test decide which request finishes first:

Code
final january = Completer<History>();
final february = Completer<History>();

when(() => repository.getHistory('january'))
    .thenAnswer((_) => january.future);
when(() => repository.getHistory('february'))
    .thenAnswer((_) => february.future);

bloc
  ..add(const MonthRequested('january'))
  ..add(const MonthRequested('february'));

february.complete(februaryHistory);
january.complete(januaryHistory);

Now assert that January cannot overwrite February when the feature promises latest-request behavior.

Prove duplicate work is blocked#

For a droppable pagination event, keep the first call open and dispatch twice:

Code
final response = Completer<ProductPage>();

when(() => repository.nextPage()).thenAnswer(
  (_) => response.future,
);

bloc
  ..add(const NextPageRequested())
  ..add(const NextPageRequested());

verify(() => repository.nextPage()).called(1);
response.complete(page);

Testing only the final list would not prove that the duplicate request was avoided.

Test retry limits#

Code
when(() => repository.load())
    .thenThrow(const TemporaryServerException());

bloc.add(const DashboardRequested());

await bloc.stream.firstWhere(
  (state) => state.status == DashboardStatus.failure,
);

verify(() => repository.load()).called(3);

Also test success on the second attempt and errors that should never retry. Retry tests should complete quickly; inject a delay policy rather than sleeping for real time.

Control timers without waiting#

Use fake_async for debouncing, timeouts, and periodic work:

Code
test('search waits for the debounce period', () {
  fakeAsync((async) {
    final controller = SearchController(repository);

    controller.queryChanged('flutter');
    async.elapse(const Duration(milliseconds: 399));
    verifyNever(() => repository.search(any()));

    async.elapse(const Duration(milliseconds: 1));
    verify(() => repository.search('flutter')).called(1);
  });
});

If timer logic cannot be tested without real waiting, move the clock, scheduler, or delay behind an injectable boundary.

Verify subscriptions are closed#

Stream-backed BLoCs can continue receiving events after close if subscriptions are leaked.

Code
await bloc.close();
connectionController.add(ConnectionStatus.offline);

expect(bloc.isClosed, isTrue);

Also verify that repository or service disposal occurs when the BLoC owns it. Ownership should be decided before writing the test.

Test state preservation#

For pagination and refresh, failure does not always mean clearing existing data. Test the exact product behavior:

  • initial failure shows a full-page error;
  • next-page failure preserves loaded items;
  • refresh replaces data only on success;
  • duplicate IDs are not appended;
  • empty first page differs from end of later pagination.

These cases reveal whether one status field is trying to represent too many situations.

Keep mocks at the BLoC boundary#

A BLoC test should normally mock use cases or repositories, not Dio and JSON. Network parsing has its own tests.

Overly deep mocks couple a state test to infrastructure and make failures difficult to understand.

The best async tests describe adversarial timelines: “the old request finishes last,” “the user taps twice,” or “the timer fires after close.” Those are the cases that turn a correct handler into an incorrect feature.

← Back to all writing