Testing BLoC/Cubit: Async State Sequences
Verifying state transitions, error paths, and event-to-state mapping with bloc_test
Open interactive version (quiz + challenge)Real-world analogy
Testing a BLoC is like testing a traffic light controller. You send it a signal (event) and verify it goes through exactly the right colour sequence in the right order. If the light goes green-red-yellow instead of green-yellow-red, the test fails — even if all three colours appeared.
What is it?
bloc_test provides a declarative way to assert that a BLoC or Cubit emits a specific sequence of states in response to events or method calls. It handles async timing, subscription management, and cleanup automatically.
Real-world relevance
On a real-time SaaS collaboration app with workspace switching, bloc_test caught a bug where WorkspaceCubit emitted LoadedState before LoadingState was emitted, causing the UI to flash the old workspace data briefly before showing the spinner.
Key points
- bloc_test package — Provides the blocTest() function, which is the standard way to test BLoC and Cubit classes. It handles async state sequences, setup, and teardown in a clean declarative API.
- blocTest structure — blocTest takes: build (creates the bloc), setUp (optional async setup), act (sends events or calls methods), expect (list of expected states in order), verify (optional mock verification), errors (expected thrown errors).
- Testing state sequences — The expect parameter is a list of states in the exact order they should be emitted. [LoadingState(), LoadedState(data)] means loading must come before loaded — order matters.
- Testing Cubit methods — For Cubits, act calls the method directly: act: (cubit) => cubit.loadUser('123'). For BLoCs, act adds events: act: (bloc) => bloc.add(LoadUserEvent('123')).
- setUp with mock repositories — Use setUp in blocTest to inject mock repositories. This ensures the BLoC gets fresh mocks for every test case, preventing state pollution between tests.
- Testing error paths — Stub your mock to throw, then expect the error state. This is critical for interview demos — showing you test failure paths proves production maturity.
- Testing loading state — Most async operations should emit a loading state first. Always test that LoadingState (or equivalent) is the first emitted state before the success or error state.
- seed parameter — Use seed to put the BLoC in a specific initial state before running the test. Useful for testing transitions from non-initial states — e.g., testing reload from an already-loaded state.
- verify parameter — After the state sequence is verified, use verify to assert that repository methods were called the expected number of times. Combines state and interaction verification.
- Testing BLoC vs Cubit — Cubit tests are simpler — call a method, expect states. BLoC tests add event dispatch. For interviews, knowing both shows understanding of the tradeoff between simplicity (Cubit) and explicitness (BLoC).
Code example
// test/presentation/blocs/user_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
class MockUserRepository extends Mock implements UserRepository {}
void main() {
late MockUserRepository mockRepo;
setUp(() {
mockRepo = MockUserRepository();
});
group('UserBloc', () {
blocTest<UserBloc, UserState>(
'emits [LoadingState, LoadedState] when LoadUserEvent succeeds',
build: () => UserBloc(userRepository: mockRepo),
setUp: () {
when(() => mockRepo.getUserById('123')).thenAnswer(
(_) async => User(id: '123', name: 'Alice'),
);
},
act: (bloc) => bloc.add(LoadUserEvent(userId: '123')),
expect: () => [
const LoadingState(),
LoadedState(user: User(id: '123', name: 'Alice')),
],
verify: (_) {
verify(() => mockRepo.getUserById('123')).called(1);
},
);
blocTest<UserBloc, UserState>(
'emits [LoadingState, ErrorState] when repository throws',
build: () => UserBloc(userRepository: mockRepo),
setUp: () {
when(() => mockRepo.getUserById('999')).thenThrow(
NetworkException('Connection refused'),
);
},
act: (bloc) => bloc.add(LoadUserEvent(userId: '999')),
expect: () => [
const LoadingState(),
const ErrorState(message: 'Connection refused'),
],
);
blocTest<UserBloc, UserState>(
'emits [LoadingState, LoadedState] from already-loaded state (reload)',
build: () => UserBloc(userRepository: mockRepo),
seed: () => LoadedState(user: User(id: '123', name: 'Alice (stale)')),
setUp: () {
when(() => mockRepo.getUserById('123')).thenAnswer(
(_) async => User(id: '123', name: 'Alice (fresh)'),
);
},
act: (bloc) => bloc.add(LoadUserEvent(userId: '123')),
expect: () => [
const LoadingState(),
LoadedState(user: User(id: '123', name: 'Alice (fresh)')),
],
);
});
}Line-by-line walkthrough
- 1. blocTest('...') — type parameters ensure compile-time safety; the compiler catches if your expected states are the wrong type
- 2. build: () => UserBloc(userRepository: mockRepo) — creates a fresh BLoC for this test; called after setUp
- 3. setUp: () { when(...).thenAnswer(...) } — stubs the mock inside blocTest's own setUp, scoped to this single test case
- 4. act: (bloc) => bloc.add(LoadUserEvent(userId: '123')) — dispatches the event that triggers state changes
- 5. expect: () => [const LoadingState(), LoadedState(user: User(...))] — the BLoC must emit exactly these two states in this order
- 6. verify: (_) { verify(() => mockRepo.getUserById('123')).called(1) } — confirms the repository was called exactly once
- 7. seed: () => LoadedState(...) — for the reload test, starts the BLoC in a pre-loaded state to test the transition path
- 8. NetworkException is thrown in setUp for the error test — blocTest expects [LoadingState, ErrorState] confirming error handling works
Spot the bug
blocTest<UserBloc, UserState>(
'emits loaded state on success',
build: () => UserBloc(userRepository: mockRepo),
act: (bloc) => bloc.add(LoadUserEvent(userId: '123')),
expect: () => [
LoadedState(user: User(id: '123', name: 'Alice')),
],
);Need a hint?
This test is missing a state. Think about what should happen between the event being added and the loaded state appearing.
Show answer
Bug: The test is missing LoadingState() as the first expected state. The BLoC almost certainly emits LoadingState before it makes the async call and emits LoadedState. The correct expect is [const LoadingState(), LoadedState(user: User(...))]. This test would pass accidentally if LoadingState is somehow not emitted, masking a real UI bug where the loading spinner never appears.
Explain like I'm 5
Imagine you have a toy robot that you press buttons on and watch the lights change. bloc_test lets you say: 'I press the GO button and I expect the light to go yellow THEN green, in that order.' If the light goes green first, the test fails — even though green eventually appeared. It is checking the exact movie, not just the last frame.
Fun fact
The bloc_test package uses Stream subscription under the hood — it subscribes to the BLoC's state stream, collects all emitted states, and then compares the list to your expected list after the act function completes.
Hands-on challenge
Write bloc_test cases for a LoginCubit with three scenarios: successful login emits [LoadingState, AuthenticatedState], wrong password emits [LoadingState, ErrorState('Invalid credentials')], and network error emits [LoadingState, ErrorState('Network error')].
More resources
- bloc_test package (pub.dev)
- Testing BLoCs — official BLoC docs (bloclibrary.dev)
- mocktail package (pub.dev)
- Effective BLoC Testing patterns (Very Good Ventures)