Advanced State Management (Bloc & Riverpod)

Testing BLoC and Cubit

16 min Lesson 12 of 14

Testing BLoC and Cubit

One of the greatest advantages of the BLoC pattern is its testability. Because BLoCs and Cubits are plain Dart classes that hold no Flutter widget infrastructure, you can unit-test every state transition in isolation — no WidgetTester, no pumpWidget, just fast, focused Dart tests. The bloc_test package provides a purpose-built blocTest helper that makes asserting emitted state sequences concise and expressive.

Why Pure Unit Tests for BLoC?

BLoCs accept events (or method calls for Cubits) and emit states as output. This input-output contract is perfect for unit testing because:

  • No widget tree is needed — tests run in milliseconds
  • Each test verifies a single, well-defined behaviour
  • Mocked repositories keep tests hermetically isolated
  • Failures are immediately traceable to business logic, not UI glue
Package setup: Add bloc_test: ^9.1.0 and mocktail: ^1.0.0 to dev_dependencies in pubspec.yaml, then run flutter pub get. bloc_test also re-exports test and stream_matchers so you rarely need to import them separately.

The blocTest Helper — Anatomy

The blocTest<B, S> function signature maps directly to the arrange-act-assert pattern:

  • build — factory that creates a fresh BLoC/Cubit instance before each test
  • seed — optional closure returning a starting state (bypasses the initial state)
  • act — closure that adds events or calls Cubit methods
  • expect — closure returning a list of matchers for emitted states, in order
  • errors — optionally assert that specific exceptions were thrown
  • verify — optional side-effect checks (e.g. repository call counts)

Testing a Cubit — CounterCubit

// counter_cubit.dart
class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);

  void increment() => emit(state + 1);
  void decrement() => emit(state - 1);
  void reset()     => emit(0);
}

// counter_cubit_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:test/test.dart';
import 'counter_cubit.dart';

void main() {
  group('CounterCubit', () {
    blocTest<CounterCubit, int>(
      'emits [1] when increment is called once',
      build: () => CounterCubit(),
      act:   (cubit) => cubit.increment(),
      expect: () => [1],
    );

    blocTest<CounterCubit, int>(
      'emits [1, 2] when increment is called twice',
      build: () => CounterCubit(),
      act:   (cubit) {
        cubit.increment();
        cubit.increment();
      },
      expect: () => [1, 2],
    );

    blocTest<CounterCubit, int>(
      'emits [0] when reset is called from seeded state 5',
      build: () => CounterCubit(),
      seed:  () => 5,
      act:   (cubit) => cubit.reset(),
      expect: () => [0],
    );

    blocTest<CounterCubit, int>(
      'emits nothing when no act is provided',
      build: () => CounterCubit(),
      expect: () => <int>[],
    );
  });
}

Testing a BLoC with Events

For a full BLoC (using events), the act closure calls bloc.add(SomeEvent()). The blocTest helper waits for the stream to settle before comparing the emitted sequence. You can use Hamcrest-style matchers such as isA<SomeState>() when the exact state value is less important than its type.

Testing an Async BLoC — WeatherBloc

// weather_state.dart
abstract class WeatherState {}
class WeatherInitial   extends WeatherState {}
class WeatherLoading   extends WeatherState {}
class WeatherLoaded    extends WeatherState {
  final String city;
  final double tempC;
  WeatherLoaded(this.city, this.tempC);
}
class WeatherError     extends WeatherState {
  final String message;
  WeatherError(this.message);
}

// weather_event.dart
abstract class WeatherEvent {}
class FetchWeather extends WeatherEvent {
  final String city;
  FetchWeather(this.city);
}

// weather_bloc.dart
class WeatherBloc extends Bloc<WeatherEvent, WeatherState> {
  final WeatherRepository repository;
  WeatherBloc(this.repository) : super(WeatherInitial()) {
    on<FetchWeather>(_onFetch);
  }

  Future<void> _onFetch(
    FetchWeather event,
    Emitter<WeatherState> emit,
  ) async {
    emit(WeatherLoading());
    try {
      final data = await repository.getWeather(event.city);
      emit(WeatherLoaded(data.city, data.tempC));
    } catch (e) {
      emit(WeatherError(e.toString()));
    }
  }
}

// weather_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';

class MockWeatherRepository extends Mock implements WeatherRepository {}

void main() {
  late MockWeatherRepository mockRepo;

  setUp(() {
    mockRepo = MockWeatherRepository();
  });

  group('WeatherBloc', () {
    blocTest<WeatherBloc, WeatherState>(
      'emits [Loading, Loaded] on successful fetch',
      build: () {
        when(() => mockRepo.getWeather('London'))
          .thenAnswer((_) async => WeatherData('London', 18.5));
        return WeatherBloc(mockRepo);
      },
      act: (bloc) => bloc.add(FetchWeather('London')),
      expect: () => [
        isA<WeatherLoading>(),
        isA<WeatherLoaded>(),
      ],
    );

    blocTest<WeatherBloc, WeatherState>(
      'emits [Loading, Error] when repository throws',
      build: () {
        when(() => mockRepo.getWeather(any()))
          .thenThrow(Exception('Network failure'));
        return WeatherBloc(mockRepo);
      },
      act: (bloc) => bloc.add(FetchWeather('Paris')),
      expect: () => [
        isA<WeatherLoading>(),
        isA<WeatherError>(),
      ],
    );
  });
}

Using seed to Test Mid-Flow Transitions

The seed parameter lets you teleport the BLoC to any state before running act. This is invaluable for testing transitions that depend on a prior state without replaying the entire event chain.

Tip: When using isA<T>() matchers, you can chain .having() to assert a specific field: isA<WeatherLoaded>().having((s) => s.city, 'city', 'London'). This keeps tests readable while still verifying payload values.

Verifying Side Effects

The optional verify callback runs after the stream closes and is ideal for asserting that the repository was called the expected number of times:

verify — Confirming Repository Interactions

blocTest<WeatherBloc, WeatherState>(
  'calls repository exactly once',
  build: () {
    when(() => mockRepo.getWeather('Berlin'))
      .thenAnswer((_) async => WeatherData('Berlin', 12.0));
    return WeatherBloc(mockRepo);
  },
  act: (bloc) => bloc.add(FetchWeather('Berlin')),
  expect: () => [isA<WeatherLoading>(), isA<WeatherLoaded>()],
  verify: (_) {
    verify(() => mockRepo.getWeather('Berlin')).called(1);
  },
);
Common pitfall: Forgetting await inside act when the Cubit method is async. Always use an async lambda — act: (cubit) async { await cubit.loadData(); } — otherwise the test may close the stream before the async work completes, causing intermittent failures.

Testing Initial State Directly

You can also test the initial state outside blocTest using a simple test block:

  • expect(CounterCubit().state, equals(0));
  • expect(WeatherBloc(mockRepo).state, isA<WeatherInitial>());

Summary

Unit-testing BLoC and Cubit classes with bloc_test is straightforward: build a fresh instance, act by adding events or calling methods, and expect an ordered list of state matchers. Use seed to start from a specific state, verify to assert repository calls, and isA<T>().having() for expressive payload checks. Because BLoCs contain no widget code, these tests are fast, deterministic, and form the cornerstone of a robust Flutter test suite.