Testing State and Status Relays
This guide covers comprehensive testing patterns for StateRelay and StatusRelay, ensuring that bloc-to-bloc communication works correctly and reliably.
Test Setup
First, let’s create proper test infrastructure:
import 'package:flutter_test/flutter_test.dart';
import 'package:juice/juice.dart';
// Test resolver for dependency injection
class TestResolver implements BlocDependencyResolver {
final Map<Type, JuiceBloc> blocs;
TestResolver(this.blocs);
@override
T resolve<T extends JuiceBloc<BlocState>>({Map<String, dynamic>? args}) {
final bloc = blocs[T];
if (bloc == null) {
throw StateError('Bloc $T not registered');
}
return bloc as T;
}
@override
BlocLease<T> lease<T extends JuiceBloc<BlocState>>({Object? scope}) {
return BlocLease<T>(resolve<T>(), () {});
}
@override
Future<void> disposeAll() async {
for (final bloc in blocs.values) {
await bloc.close();
}
}
}
void main() {
late AuthBloc authBloc;
late ProfileBloc profileBloc;
late TestResolver resolver;
setUp(() {
authBloc = AuthBloc();
profileBloc = ProfileBloc();
resolver = TestResolver({
AuthBloc: authBloc,
ProfileBloc: profileBloc,
});
});
tearDown(() async {
await resolver.disposeAll();
});
}
Testing StateRelay
Basic State Transformation
test('StateRelay transforms and forwards state changes', () async {
final relay = StateRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (state) => LoadProfileEvent(userId: state.userId!),
when: (state) => state.isAuthenticated,
resolver: resolver,
);
// Wait for async initialization
await Future.delayed(const Duration(milliseconds: 100));
// Trigger state change
await authBloc.send(LoginEvent(userId: '123'));
await Future.delayed(const Duration(milliseconds: 100));
// Verify destination bloc received the event
expect(profileBloc.state.profile?.userId, equals('123'));
await relay.close();
});
Testing the when Predicate
test('StateRelay filters with when predicate', () async {
final relay = StateRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (state) => LoadProfileEvent(userId: state.userId!),
when: (state) => state.isAuthenticated && state.userId != null,
resolver: resolver,
);
await Future.delayed(const Duration(milliseconds: 100));
// Send event that doesn't pass filter (not authenticated)
await authBloc.send(UpdateUserEvent(userId: '123', authenticated: false));
await Future.delayed(const Duration(milliseconds: 100));
// Profile should not be loaded
expect(profileBloc.state.profile, isNull);
// Now authenticate
await authBloc.send(LoginEvent(userId: '123'));
await Future.delayed(const Duration(milliseconds: 100));
// Profile should now be loaded
expect(profileBloc.state.profile?.userId, equals('123'));
await relay.close();
});
Testing Relay Lifecycle
group('StateRelay lifecycle', () {
test('closes cleanly', () async {
final relay = StateRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (state) => LoadProfileEvent(userId: state.userId!),
resolver: resolver,
);
await Future.delayed(const Duration(milliseconds: 100));
// Close should not throw
await relay.close();
expect(relay.isClosed, isTrue);
// Multiple close calls should be safe
await relay.close();
});
test('stops relaying after close', () async {
final relay = StateRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (state) => LoadProfileEvent(userId: state.userId!),
when: (state) => state.isAuthenticated,
resolver: resolver,
);
await Future.delayed(const Duration(milliseconds: 100));
// Initial relay works
await authBloc.send(LoginEvent(userId: '123'));
await Future.delayed(const Duration(milliseconds: 100));
expect(profileBloc.state.profile?.userId, equals('123'));
// Close relay
await relay.close();
// Clear profile state
await profileBloc.send(ClearProfileEvent());
await Future.delayed(const Duration(milliseconds: 50));
// Send another auth event - should not relay
await authBloc.send(LoginEvent(userId: '456'));
await Future.delayed(const Duration(milliseconds: 100));
// Profile should still be cleared
expect(profileBloc.state.profile, isNull);
});
});
Testing Error Handling
test('StateRelay handles transformer errors without closing', () async {
int callCount = 0;
final relay = StateRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (state) {
callCount++;
if (callCount == 2) {
throw Exception('Transformer error');
}
return LoadProfileEvent(userId: state.userId!);
},
resolver: resolver,
);
await Future.delayed(const Duration(milliseconds: 100));
// First event works
await authBloc.send(LoginEvent(userId: '123'));
await Future.delayed(const Duration(milliseconds: 100));
expect(profileBloc.state.profile?.userId, equals('123'));
// Second event throws - relay should continue
await authBloc.send(LoginEvent(userId: '456'));
await Future.delayed(const Duration(milliseconds: 100));
// Relay should still be active
expect(relay.isClosed, isFalse);
// Third event should work
await authBloc.send(LoginEvent(userId: '789'));
await Future.delayed(const Duration(milliseconds: 100));
expect(profileBloc.state.profile?.userId, equals('789'));
await relay.close();
});
Testing StatusRelay
Basic Status Transformation
test('StatusRelay transforms and forwards StreamStatus', () async {
final relay = StatusRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (status) {
if (status is UpdatingStatus<AuthState>) {
return LoadProfileEvent(userId: status.state.userId!);
}
return ClearProfileEvent();
},
resolver: resolver,
);
await Future.delayed(const Duration(milliseconds: 100));
await authBloc.send(LoginEvent(userId: '123'));
await Future.delayed(const Duration(milliseconds: 100));
expect(profileBloc.state.profile?.userId, equals('123'));
await relay.close();
});
Testing Full Status Handling
test('StatusRelay handles all status types', () async {
final receivedEvents = <Type>[];
final relay = StatusRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (status) => status.when(
updating: (state, _, __) {
receivedEvents.add(UpdatingStatus);
return state.isAuthenticated
? LoadProfileEvent(userId: state.userId!)
: ClearProfileEvent();
},
waiting: (_, __, ___) {
receivedEvents.add(WaitingStatus);
return ProfileLoadingEvent();
},
failure: (_, __, ___) {
receivedEvents.add(FailureStatus);
return ClearProfileEvent();
},
canceling: (_, __, ___) {
receivedEvents.add(CancelingStatus);
return ClearProfileEvent();
},
),
resolver: resolver,
);
await Future.delayed(const Duration(milliseconds: 100));
// Trigger login which may emit waiting then updating
await authBloc.send(LoginEvent(userId: '123'));
await Future.delayed(const Duration(milliseconds: 200));
// Should have received at least updating status
expect(receivedEvents, contains(UpdatingStatus));
await relay.close();
});
Testing Status Filtering
test('StatusRelay filters with when predicate on status', () async {
int relayCount = 0;
final relay = StatusRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (status) {
relayCount++;
return LoadProfileEvent(userId: status.state.userId ?? 'default');
},
when: (status) => status is UpdatingStatus<AuthState>,
resolver: resolver,
);
await Future.delayed(const Duration(milliseconds: 100));
await authBloc.send(LoginEvent(userId: '123'));
await Future.delayed(const Duration(milliseconds: 100));
// Only updating statuses should trigger relay
expect(relayCount, greaterThan(0));
await relay.close();
});
Testing Race Conditions
group('Race condition handling', () {
test('close during initialization does not cause errors', () async {
final relay = StateRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (state) => LoadProfileEvent(userId: state.userId!),
resolver: resolver,
);
// Immediately close before initialization completes
await relay.close();
// Wait for any pending microtasks
await Future.delayed(const Duration(milliseconds: 100));
// Should not throw
expect(relay.isClosed, isTrue);
});
test('multiple relays on same source work independently', () async {
final destBloc1 = ProfileBloc();
final destBloc2 = ProfileBloc();
final resolver1 = TestResolver({
AuthBloc: authBloc,
ProfileBloc: destBloc1,
});
final resolver2 = TestResolver({
AuthBloc: authBloc,
ProfileBloc: destBloc2,
});
final relay1 = StateRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (state) => LoadProfileEvent(userId: 'relay1-${state.userId}'),
resolver: resolver1,
);
final relay2 = StateRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (state) => LoadProfileEvent(userId: 'relay2-${state.userId}'),
resolver: resolver2,
);
await Future.delayed(const Duration(milliseconds: 100));
await authBloc.send(LoginEvent(userId: '123'));
await Future.delayed(const Duration(milliseconds: 100));
// Both should have received events
expect(destBloc1.state.profile?.userId, equals('relay1-123'));
expect(destBloc2.state.profile?.userId, equals('relay2-123'));
// Close one relay
await relay1.close();
await authBloc.send(LoginEvent(userId: '456'));
await Future.delayed(const Duration(milliseconds: 100));
// Only relay2 should update
expect(destBloc1.state.profile?.userId, equals('relay1-123'));
expect(destBloc2.state.profile?.userId, equals('relay2-456'));
await relay2.close();
await destBloc1.close();
await destBloc2.close();
});
test('handles dest bloc close gracefully', () async {
final relay = StateRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (state) => LoadProfileEvent(userId: state.userId!),
resolver: resolver,
);
await Future.delayed(const Duration(milliseconds: 100));
// Close destination bloc
await profileBloc.close();
// Send event to source - relay should detect closed dest
await authBloc.send(LoginEvent(userId: '123'));
await Future.delayed(const Duration(milliseconds: 100));
expect(relay.isClosed, isTrue);
});
test('handles source bloc close gracefully', () async {
final relay = StateRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (state) => LoadProfileEvent(userId: state.userId!),
resolver: resolver,
);
await Future.delayed(const Duration(milliseconds: 100));
// Close source bloc
await authBloc.close();
await Future.delayed(const Duration(milliseconds: 100));
expect(relay.isClosed, isTrue);
});
});
Integration Tests
group('Integration tests', () {
test('complete auth flow with profile relay', () async {
final relay = StateRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (state) => state.isAuthenticated
? LoadProfileEvent(userId: state.userId!)
: ClearProfileEvent(),
resolver: resolver,
);
await Future.delayed(const Duration(milliseconds: 100));
// Login
await authBloc.send(LoginEvent(userId: '123'));
await Future.delayed(const Duration(milliseconds: 100));
expect(profileBloc.state.profile?.userId, equals('123'));
// Logout
await authBloc.send(LogoutEvent());
await Future.delayed(const Duration(milliseconds: 100));
expect(profileBloc.state.profile, isNull);
// Login with different user
await authBloc.send(LoginEvent(userId: '456'));
await Future.delayed(const Duration(milliseconds: 100));
expect(profileBloc.state.profile?.userId, equals('456'));
await relay.close();
});
});
Testing Utilities
/// Waits for a stream to emit a matching value
Future<T> waitFor<T>(
Stream<T> stream,
bool Function(T) predicate, {
Duration timeout = const Duration(seconds: 5),
}) async {
final completer = Completer<T>();
final subscription = stream.listen((value) {
if (predicate(value)) {
completer.complete(value);
}
});
try {
return await completer.future.timeout(timeout);
} finally {
await subscription.cancel();
}
}
/// Collects all values emitted within a duration
Future<List<T>> collectFor<T>(
Stream<T> stream,
Duration duration,
) async {
final values = <T>[];
final subscription = stream.listen(values.add);
await Future.delayed(duration);
await subscription.cancel();
return values;
}
Best Practices
1. Test Setup
- Initialize mocks and resolvers in
setUp - Clean up resources in
tearDown - Use fresh instances for each test
2. Timing
- Allow time for async initialization (
Future.delayed) - Don’t use exact timing assertions
- Use stream matchers when possible
3. Isolation
- Test relays independently
- Mock external dependencies
- Use test resolvers instead of BlocScope
4. Coverage
- Test all status types for StatusRelay
- Test the
whenpredicate - Test error scenarios
- Test cleanup and disposal
5. Common Pitfalls to Avoid
// ❌ Bad: Not waiting for initialization
final relay = StateRelay<...>(...);
authBloc.send(LoginEvent()); // May not work!
// ✅ Good: Wait for initialization
final relay = StateRelay<...>(...);
await Future.delayed(Duration(milliseconds: 100));
authBloc.send(LoginEvent());
// ❌ Bad: Not cleaning up
test('my test', () async {
final relay = StateRelay<...>(...);
// Test code...
// Relay not closed!
});
// ✅ Good: Always clean up
test('my test', () async {
final relay = StateRelay<...>(...);
try {
// Test code...
} finally {
await relay.close();
}
});
Migration from RelayUseCaseBuilder Tests
If you have existing tests for RelayUseCaseBuilder, here’s how to migrate:
// Before (deprecated):
test('relay test', () async {
final relay = RelayUseCaseBuilder<AuthBloc, ProfileBloc, AuthState>(
typeOfEvent: LoadProfileEvent,
useCaseGenerator: () => LoadProfileUseCase(),
statusToEventTransformer: (status) => LoadProfileEvent(
userId: status.state.userId,
),
resolver: resolver,
);
// ...
});
// After - StateRelay:
test('relay test', () async {
final relay = StateRelay<AuthBloc, ProfileBloc, AuthState>(
toEvent: (state) => LoadProfileEvent(userId: state.userId!),
when: (state) => state.isAuthenticated,
resolver: resolver,
);
// ...
});
Key differences:
- Replace
statusToEventTransformerwithtoEvent - Add
whenfor filtering instead of checking in transformer - Remove
typeOfEventanduseCaseGenerator - Test assertions remain the same