Testing BLoC and Cubit Effectively

FlutterDart
Share on LinkedIn

The checkout Cubit looked fine in the widget test—until a race condition emitted CheckoutError after CheckoutSuccess because the mock repository didn't match production timing. BLoC and Cubit tests belong at the unit layer, below widgets, with mocked dependencies and explicit state sequences. Done right, they run in milliseconds and catch logic bugs that integration tests miss because they're too slow to cover every edge case.

Test pyramid for Bloc apps

Integration tests     ← few, critical paths
Widget tests          ← UI renders correct state
Bloc/Cubit unit tests ← many, all state transitions  ← you are here
Repository unit tests ← data parsing, error mapping

A Cubit test answers: "Given state X, when I call method Y, does it emit Z1 then Z2?" No pumping widgets required.

Setup: dependencies and packages

dev_dependencies:
  bloc_test: ^9.1.7
  mocktail: ^1.0.4
  flutter_test:
    sdk: flutter
class MockCartRepository extends Mock implements CartRepository {}

Mocktail avoids codegen; mockito works equally well if you prefer @GenerateMocks.

Testing a Cubit with blocTest

void main() {
  late CartRepository repository;
  late CartCubit cubit;

  setUp(() {
    repository = MockCartRepository();
    cubit = CartCubit(repository);
  });

  tearDown(() => cubit.close());

  blocTest<CartCubit, CartState>(
    'emits [loading, loaded] when loadCart succeeds',
    build: () {
      when(() => repository.fetchCart())
          .thenAnswer((_) async => [Item(id: '1', name: 'Widget')]);
      return cubit;
    },
    act: (c) => c.loadCart(),
    expect: () => [
      const CartState.loading(),
      CartState.loaded(items: [Item(id: '1', name: 'Widget')]),
    ],
    verify: (_) {
      verify(() => repository.fetchCart()).called(1);
    },
  );
}

blocTest handles stream listening, timing, and cubit lifecycle. The expect list must match emissions exactly in order.

Testing error paths

Don't only test happy paths:

blocTest<CartCubit, CartState>(
  'emits [loading, error] when fetch fails',
  build: () {
    when(() => repository.fetchCart())
        .thenThrow(NetworkException('offline'));
    return cubit;
  },
  act: (c) => c.loadCart(),
  expect: () => [
    const CartState.loading(),
    const CartState.error(message: 'offline'),
  ],
);

Map exceptions to user-facing messages in the Cubit, not in the widget—test the mapping here.

Seeded state for mid-flow tests

Test behavior when Cubit already holds state:

blocTest<CartCubit, CartState>(
  'addItem appends to existing cart',
  seed: () => CartState.loaded(items: [existingItem]),
  build: () {
    when(() => repository.addItem(any()))
        .thenAnswer((_) async => {});
    return cubit;
  },
  act: (c) => c.addItem(newItem),
  expect: () => [
    CartState.loaded(items: [existingItem, newItem]),
  ],
);

seed sets initial state before act—essential for testing incremental updates, pagination append, optimistic UI rollback.

Testing Blocs with events

blocTest<SearchBloc, SearchState>(
  'debounced search emits results',
  build: () {
    when(() => repository.search('flutter'))
        .thenAnswer((_) async => searchResults);
    return SearchBloc(repository);
  },
  act: (bloc) {
    bloc.add(SearchQueryChanged('f'));
    bloc.add(SearchQueryChanged('fl'));
    bloc.add(SearchQueryChanged('flutter'));
  },
  wait: const Duration(milliseconds: 400), // debounce window
  expect: () => [
    SearchState.loading(),
    SearchState.loaded(searchResults),
  ],
  skip: 2, // skip intermediate loading states from partial queries
);

Use wait for async delays and skip when intermediate emissions aren't the focus. expect can also be a matcher function for flexible assertions.

Testing stream subscriptions

Cubits listening to external streams need cleanup verification:

blocTest<AuthCubit, AuthState>(
  'reacts to auth stream updates',
  build: () {
    when(() => authRepository.authStateChanges)
        .thenAnswer((_) => Stream.value(AuthUser(id: '1')));
    return AuthCubit(authRepository);
  },
  expect: () => [AuthState.authenticated(userId: '1')],
);

// In tearDown or separate test:
test('cancels subscription on close', () async {
  final controller = StreamController<AuthUser>();
  when(() => authRepository.authStateChanges)
      .thenAnswer((_) => controller.stream);
  final cubit = AuthCubit(authRepository);
  await cubit.close();
  expect(controller.hasListener, isFalse);
});

Anti-patterns to avoid

  1. Widget testing Cubit logic — slow, brittle, wrong layer.
  2. Real HTTP in Cubit tests — flaky, slow; mock the repository.
  3. Ignoring close() — causes "Cannot add new events after calling close" in later tests. Always tearDown.
  4. Testing private methods — test through public API; private helpers are implementation details.
  5. Overspecified expect lists — if loading state duration varies, use skip or predicate matchers.

Organizing test files

Mirror source structure:

lib/features/cart/cubit/cart_cubit.dart
test/features/cart/cubit/cart_cubit_test.dart

Group with group('CartCubit', () {...}) per method: loadCart, addItem, removeItem. Name tests as behavior: 'emits error when network unavailable'.

Aim for every public method × (success, failure, edge case). Coverage tools should show near-100% on Cubit files—that's cheap and valuable.

Coverage for stream-heavy Cubits

When Cubits subscribe to Firebase or WebSocket streams, test subscription lifecycle explicitly:

blocTest<ChatCubit, ChatState>(
  'emits messages from stream',
  build: () {
    when(() => chatService.messages).thenAnswer((_) => Stream.fromIterable([
      Message(text: 'hi'),
    ]));
    return ChatCubit(chatService);
  },
  expect: () => [ChatState(messages: [Message(text: 'hi')])],
);

Use fake async and controlled StreamControllers to simulate reconnect scenarios—disconnect mid-test, verify Cubit emits error then recovery state.

Golden tests complement Cubit tests—state emission correctness doesn't guarantee widget renders error UI. Combine blocTest for logic with widget tests pumping BlocProvider and asserting finder text matches emitted error states from mocked Cubit.

Production teams should treat this guidance as living documentation: revisit assumptions after major platform upgrades, measure outcomes with real metrics rather than checklist compliance, and pair written standards with automated checks in CI. The patterns here reflect what held up in shipped apps—not theoretical perfection. Adapt thresholds, timeouts, and tooling to your stack, but keep the underlying principles: explicit configuration, testable behavior, and failure modes users can understand.

When onboarding new engineers, walk through one end-to-end example in a debug build before asking them to extend the pattern. Most failures I have seen came from skipped platform setup steps—manifest entries, API keys, code generation, or permission prompts—not from misunderstanding the Dart layer. Keep a troubleshooting section in your team wiki linking official docs and the exact commands that worked for your last upgrade.

Resources

Frequently asked questions

How do I test a Cubit in Flutter?

Instantiate the Cubit with mocked dependencies, call public methods, and expect emitted states in order. Use blocTest from bloc_test package for declarative setup-act-assert with automatic stream verification. Pure Cubits without side effects can also be tested by reading state directly after method calls.

What is the difference between testing Bloc and Cubit?

Cubits expose methods that emit states directly—tests call methods and assert emissions. Blocs receive events and may emit multiple states asynchronously—tests dispatch events and verify the emission sequence. Blocs with complex event transformers need blocTest's wait and skip parameters to handle timing.

Should I mock repositories when testing BLoC?

Yes—unit tests for Blocs should mock data layers to control success, failure, and latency scenarios. Use mocktail or mockito to stub repository methods. Reserve real repository integration tests for a separate test layer; Bloc tests should fail fast and run in milliseconds.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →