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
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 officialbloc-signals
plugin includes a pre-builttesting skill(plugins/bloc-signals/skills/bloc-signals/
) that automatically teaches your AI assistant these exact testing conventions, observer scoping rules, and declarativeblocSignalTest
patterns!
| 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. |
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();
});
});
}
💡
Why it’s easier than BLoC: You don't needawait bloc.stream.first
orexpectLater()
. What you call is what you immediately assert. Furthermore, if an assertion fails,BlocSignalBase.toString()
outputsCounterCubit(1)
instead of genericInstance of 'CounterCubit'
, making test failure diagnostics crystal clear.
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],
);
});
}
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],
);
});
}
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!
);
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(Auth());
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 [Auth, AuthAuthenticated] on successful login',
build: () => AuthBloc(MockAuthRepository(success: true)),
act: (bloc) => bloc.add(LoginRequested('user@example.com', 'pass123')),
expect: () => [
Auth(),
AuthAuthenticated(User(id: '1', email: 'user@example.com')),
],
);
blocSignalTest<AuthBloc, AuthState>(
'emits [Auth, AuthFailure] and captures error on failure',
build: () => AuthBloc(MockAuthRepository(success: false)),
act: (bloc) => bloc.add(LoginRequested('user@example.com', 'wrong')),
expect: () => [
Auth(),
AuthFailure('Unauthorized'),
],
errors: () => [
isA<UnauthorizedException>(),
],
);
});
}
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}));
},
);
}
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));
},
);
});
}
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/
):blocSignalTest
unit tests following clean 100% coverage patterns.build()
to capture onCreate
lifecycle events.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
Unit testing state machines doesn't have to mean fighting asynchronous microtask streams or writing boilerplate pump loops.
With ** BlocSignal** and
bloc_signals_test
dart test
without Flutter engine startup overhead.package:bloc_test
.package:bloc_signals_test
Happy testing! 🧪✨