Lesson 33 of 77 advanced

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

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. 1. blocTest('...') — type parameters ensure compile-time safety; the compiler catches if your expected states are the wrong type
  2. 2. build: () => UserBloc(userRepository: mockRepo) — creates a fresh BLoC for this test; called after setUp
  3. 3. setUp: () { when(...).thenAnswer(...) } — stubs the mock inside blocTest's own setUp, scoped to this single test case
  4. 4. act: (bloc) => bloc.add(LoadUserEvent(userId: '123')) — dispatches the event that triggers state changes
  5. 5. expect: () => [const LoadingState(), LoadedState(user: User(...))] — the BLoC must emit exactly these two states in this order
  6. 6. verify: (_) { verify(() => mockRepo.getUserById('123')).called(1) } — confirms the repository was called exactly once
  7. 7. seed: () => LoadedState(...) — for the reload test, starts the BLoC in a pre-loaded state to test the transition path
  8. 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

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery