DEV Community

Cover image for Unit Testing in BlocSignal: The Practical Handbook

Unit Testing in BlocSignal: The Practical Handbook

A Practical Guide to Faster, Deterministic Flutter & Dart Unit Testing

If you’ve ever written unit tests for classic package:bloc applications using bloc_test, you know the drill: build your BLoC, dispatch an event in act, and assert state emissions in expect.

Under the hood, classic BLoC processes state updates asynchronously via Dart microtask-queue Streams. While robust, testing asynchronous streams can introduce microtask timing headaches, race conditions, or the need to drain queues or use fakeAsync when testing complex side-effects.

In BlocSignal, state updates propagate synchronously. Calling emit(newState) updates the underlying signal graph in the exact same call stack frame.

This handbook is a practical, recipe-based guide to testing BlocSignal and CubitSignal applications using package:bloc_signals_test. Whether you’re coming from classic BLoC or brand new to Signals, this guide shows you how to test every scenario cleanly—and why it’s significantly easier than classic stream-based testing.

🤖 AI Assistant Tip: Working with an AI coding assistant (like Antigravity, Gemini CLI, or Cursor)? The official bloc-signals plugin includes a pre-built testing skill (plugins/bloc-signals/skills/bloc-signals/) that automatically teaches your AI assistant these exact testing conventions, observer scoping rules, and declarative blocSignalTest patterns!


🛠️ Quick Reference: BLoC Streams vs. BlocSignal Testing

Testing Task Classic BLoC (package:bloc_test) BlocSignal (package:bloc_signals_test) Why it’s easier in BlocSignal
Execution Environment Often requires flutter test engine Pure dart test execution Blazing Speed: Business logic tests run in pure Dart CLI without booting Flutter UI engine.
Simple State Assertions Requires async stream listener or blocTest Direct expect(cubit.state, 1) or blocSignalTest Synchronous: State updates on the next line of code without microtask delay.
Failure Diagnostics Legacy Instance of 'CounterCubit' Built-in toString(): CounterCubit(0) Clear Logs: Failed assertions print state value directly in console.
State Seeding seed: () => State(...) build: () => MyCubit(initialState: ...) Direct Constructor Seeding: No hidden seed queue or stream overrides.
Concurrency Transformers Requires fakeAsync / async timer pumps Pure Dart Future / Mutex locks Deterministic Execution: No microtask stream queue lagging behind event dispatches.
De-duplication Testing Dependent on Equatable mixins Built-in == equality de-duplication Automatic: Duplicate states never trigger redundant test steps or UI builds.

📖 Recipe Handbook

Recipe 1: Direct Imperative Unit Testing (Zero Helper Overhead)

Because state updates in BlocSignal and CubitSignal happen synchronously, you don’t need any helper package or async pump for straightforward unit tests! You can inspect cubit.state immediately on the next line of code:

import 'package:bloc_signals/bloc_signals.dart';
import 'package:test/test.dart';

class CounterCubit extends CubitSignal<int> {
  CounterCubit([super.initialState = 0]);

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

void main() {
  group('CounterCubit (Direct Synchronous Testing)', () {
    test('initial state is 0', () {
      final cubit = CounterCubit();
      expect(cubit.state, equals(0));
      cubit.close();
    });

    test('increment updates state synchronously in the same call frame', () {
      final cubit = CounterCubit();

      cubit.increment();
      // No await, no microtask pump, no stream listener delay!
      expect(cubit.state, equals(1));

      cubit.increment();
      expect(cubit.state, equals(2));

      cubit.close();
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

💡 Why it’s easier than BLoC: You don't need await bloc.stream.first or expectLater(). What you call is what you immediately assert. Furthermore, if an assertion fails, BlocSignalBase.toString() outputs CounterCubit(1) instead of generic Instance of 'CounterCubit', making test failure diagnostics crystal clear.


Recipe 2: Declarative Testing with blocSignalTest

For structured test suites, package:bloc_signals_test provides the blocSignalTest helper. It mirrors the exact API of blocTest from package:bloc_test so BLoC developers feel right at home:

import 'package:bloc_signals_test/bloc_signals_test.dart';
import 'package:test/test.dart';

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

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

    blocSignalTest<CounterCubit, int>(
      'supports state seeding directly in build()',
      build: () => CounterCubit(10), // Seeded with 10
      act: (cubit) => cubit.increment(),
      expect: () => [11],
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

Recipe 3: Testing Reified Events & Streamless Concurrency Transformers

When testing event-driven BlocSignal classes (bloc.add(event)), blocSignalTest records every state transition triggered by your event handlers.

In addition, BlocSignal supports streamless event concurrency transformers (droppable(), sequential(), restartable(), Mutex) built on pure Dart higher-order functions:

sealed class CounterEvent {}
class IncrementEvent extends CounterEvent {}
class DecrementEvent extends CounterEvent {}

class CounterBloc extends BlocSignal<CounterEvent, int> {
  CounterBloc() : super(0) {
    // Pass concurrency transformers directly without Rx Streams:
    on<IncrementEvent>(
      (event, emit) => emit(state + 1),
      transformer: sequential(),
    );
    on<DecrementEvent>(
      (event, emit) => emit(state - 1),
      transformer: droppable(),
    );
  }
}

void main() {
  group('CounterBloc Event Testing', () {
    blocSignalTest<CounterBloc, int>(
      'emits [1, 0] when IncrementEvent and DecrementEvent are added',
      build: () => CounterBloc(),
      act: (bloc) {
        bloc.add(IncrementEvent());
        bloc.add(DecrementEvent());
      },
      expect: () => [1, 0],
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

Automatic De-duplication

Signals automatically de-duplicate identical states using == equality. Re-emitting an identical state is safely ignored without triggering redundant test steps or UI rebuilds:

class UserCubit extends CubitSignal<String> {
  UserCubit() : super('Alice');

  void updateName(String name) => emit(name);
}

blocSignalTest<UserCubit, String>(
  'automatically de-duplicates identical state emissions',
  build: () => UserCubit(),
  act: (cubit) => cubit.updateName('Alice'), // Same as initial state
  expect: () => [], // No redundant emission!
);
Enter fullscreen mode Exit fullscreen mode

Recipe 4: Testing Asynchronous APIs & Error Routing

When an event handler triggers asynchronous Futures (such as REST API calls or database queries), operational exceptions are captured automatically and routed to onError. blocSignalTest allows you to assert both state transitions and caught exceptions:

class AuthBloc extends BlocSignal<AuthEvent, AuthState> {
  final AuthRepository repository;

  AuthBloc(this.repository) : super(AuthInitial()) {
    on<LoginRequested>((event, emit) async {
      emit(AuthLoading());
      try {
        final user = await repository.login(event.email, event.password);
        emit(AuthAuthenticated(user));
      } catch (e) {
        emit(AuthFailure(e.toString()));
      }
    });
  }
}

void main() {
  group('AuthBloc Async Tests', () {
    blocSignalTest<AuthBloc, AuthState>(
      'emits [AuthLoading, AuthAuthenticated] on successful login',
      build: () => AuthBloc(MockAuthRepository(success: true)),
      act: (bloc) => bloc.add(LoginRequested('user@example.com', 'pass123')),
      expect: () => [
        AuthLoading(),
        AuthAuthenticated(User(id: '1', email: 'user@example.com')),
      ],
    );

    blocSignalTest<AuthBloc, AuthState>(
      'emits [AuthLoading, AuthFailure] and captures error on failure',
      build: () => AuthBloc(MockAuthRepository(success: false)),
      act: (bloc) => bloc.add(LoginRequested('user@example.com', 'wrong')),
      expect: () => [
        AuthLoading(),
        AuthFailure('Unauthorized'),
      ],
      errors: () => [
        isA<UnauthorizedException>(),
      ],
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

Recipe 5: Testing Hydrated Persistence & Replay Undo/Redo

When using satellite packages like bloc_signals_hydrate or bloc_signals_replay, testing state persistence and undo/redo stacks is completely synchronous:

// Testing HydratedCubitSignal with in-memory storage mock:
void main() {
  setUp(() {
    HydratedStorage.storage = MockHydratedStorage();
  });

  blocSignalTest<HydratedCounterCubit, int>(
    'restores persisted state on instantiation',
    build: () => HydratedCounterCubit(),
    act: (cubit) => cubit.increment(),
    verify: (cubit) {
      expect(HydratedStorage.storage.read('HydratedCounterCubit'), equals({'value': 1}));
    },
  );
}
Enter fullscreen mode Exit fullscreen mode

Recipe 6: Testing Observers & Global Telemetry Scoping

If you are testing custom BlocSignalObserver implementations (such as OpenTelemetry tracing or logging observers), blocSignalTest automatically manages observer setup before build() is invoked—ensuring onCreate, onEvent, onTransition, onChange, and onClose lifecycle events are captured cleanly:

void main() {
  group('Observer Telemetry Scoping', () {
    late TestObserver testObserver;

    setUp(() {
      testObserver = TestObserver();
    });

    blocSignalTest<CounterCubit, int>(
      'captures onCreate and onClose in test observer',
      build: () => CounterCubit(),
      act: (cubit) => cubit.increment(),
      verify: (cubit) {
        expect(testObserver.createdContainers, hasLength(1));
        expect(testObserver.transitions, hasLength(1));
      },
    );
  });
}
Enter fullscreen mode Exit fullscreen mode

🤖 Built-in AI Agent Testing Skill

One of the biggest advantages of BlocSignal is its first-class AI agent integration.

When building or testing applications with AI coding tools (such as Antigravity, Gemini CLI, or Cursor), the official bloc-signals plugin bundles a dedicated agent skill (plugins/bloc-signals/skills/bloc-signals/):

  • Automated Test Scaffolding: Teaches AI agents to write declarative blocSignalTest unit tests following clean 100% coverage patterns.
  • Observer Scoping Rules: Ensures AI assistants attach test observers before build() to capture onCreate lifecycle events.
  • Synchronous Assertion Guidance: Prevents AI tools from adding unnecessary await tester.pumpAndSettle() or Future.delayed calls when testing pure signal state updates.

You can validate your local AI agent setup at any time by running:

dart run tool/validate_agent_plugin.dart
Enter fullscreen mode Exit fullscreen mode

🚀 Conclusion

Unit testing state machines doesn't have to mean fighting asynchronous microtask streams or writing boilerplate pump loops.

With BlocSignal and bloc_signals_test:

  • State updates propagate synchronously in the exact frame they occur.
  • Pure Dart core tests run in milliseconds via dart test without Flutter engine startup overhead.
  • The API is 100% familiar to anyone who knows package:bloc_test.
  • AI coding agents come equipped with pre-built skills to help you write 100% covered test suites effortlessly.

Resources & Links

Happy testing! 🧪✨

Top comments (0)