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#
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:
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:
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#
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:
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.
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.