INTERMEDIATEtesting⏱ 25 min read
Testing Patterns for Reactive Applications
Comprehensive guide to testing reactive applications with unit, widget, and integration testing strategies
Reactiv TeamUpdated 1/15/2025
📚 Prerequisites
- Basic understanding of reactive programming concepts
- Familiarity with Dart testing framework
- Knowledge of Reactiv Core API
#testing#unit-testing#widget-testing#integration-testing#mocking#rxdart
Testing Patterns for Reactive Applications
Testing reactive applications requires special considerations due to their asynchronous nature and stream-based architecture. This guide covers comprehensive testing strategies for Reactiv applications.
Table of Contents
- Why Testing Reactive Code is Different
- Unit Testing Streams
- Testing State Management
- Widget Testing with Reactive Components
- Integration Testing
- Mocking and Stubbing
- Testing Error Handling
- Performance Testing
- Best Practices
Why Testing Reactive Code is Different
Reactive programming introduces unique testing challenges:
- Asynchronous behavior: Streams emit values over time
- Subscription management: Need to properly dispose subscriptions
- State changes: Multiple state transitions must be verified
- Timing issues: Race conditions and ordering of events
Key Principles:
- Test stream emissions in order
- Verify subscription and disposal
- Test both success and error paths
- Use time-based testing for debouncing/throttling
Unit Testing Streams
Basic Stream Testing
import 'package:test/test.dart';
import 'package:reactiv_core/reactiv_core.dart';
void main() {
group('Counter Stream Tests', () {
late ReactiveValue<int> counter;
setUp(() {
counter = ReactiveValue<int>(0);
});
tearDown(() {
counter.dispose();
});
test('emits initial value', () {
expect(counter.value, equals(0));
});
test('emits values when updated', () async {
// Listen to stream
final emissions = <int>[];
final subscription = counter.stream.listen(emissions.add);
// Update values
counter.value = 1;
counter.value = 2;
counter.value = 3;
// Wait for async processing
await Future.delayed(Duration.zero);
// Verify emissions
expect(emissions, equals([0, 1, 2, 3]));
// Clean up
await subscription.cancel();
});
test('emits distinct values only', () async {
final distinctCounter = ReactiveValue<int>(
0,
distinct: true,
);
final emissions = <int>[];
final subscription = distinctCounter.stream.listen(emissions.add);
distinctCounter.value = 1;
distinctCounter.value = 1; // Duplicate
distinctCounter.value = 2;
distinctCounter.value = 2; // Duplicate
distinctCounter.value = 3;
await Future.delayed(Duration.zero);
expect(emissions, equals([0, 1, 2, 3]));
await subscription.cancel();
distinctCounter.dispose();
});
});
}
Testing Stream Transformations
test('transforms stream values correctly', () async {
final source = ReactiveValue<int>(1);
final doubled = source.stream.map((value) => value * 2);
final emissions = <int>[];
final subscription = doubled.listen(emissions.add);
source.value = 2;
source.value = 3;
source.value = 4;
await Future.delayed(Duration.zero);
expect(emissions, equals([2, 4, 6, 8]));
await subscription.cancel();
source.dispose();
});
test('filters stream values correctly', () async {
final source = ReactiveValue<int>(1);
final evens = source.stream.where((value) => value % 2 == 0);
final emissions = <int>[];
final subscription = evens.listen(emissions.add);
source.value = 2; // Emits
source.value = 3; // Filtered
source.value = 4; // Emits
source.value = 5; // Filtered
await Future.delayed(Duration.zero);
expect(emissions, equals([2, 4]));
await subscription.cancel();
source.dispose();
});
Testing State Management
Testing Reactive State Classes
class CounterState {
final ReactiveValue<int> count = ReactiveValue<int>(0);
final ReactiveValue<bool> isLoading = ReactiveValue<bool>(false);
void increment() {
count.value++;
}
void decrement() {
count.value--;
}
Future<void> asyncIncrement() async {
isLoading.value = true;
await Future.delayed(Duration(milliseconds: 100));
count.value++;
isLoading.value = false;
}
void dispose() {
count.dispose();
isLoading.dispose();
}
}
// Tests
group('CounterState Tests', () {
late CounterState state;
setUp(() {
state = CounterState();
});
tearDown(() {
state.dispose();
});
test('initial values are correct', () {
expect(state.count.value, equals(0));
expect(state.isLoading.value, equals(false));
});
test('increment increases count', () {
state.increment();
expect(state.count.value, equals(1));
state.increment();
expect(state.count.value, equals(2));
});
test('asyncIncrement sets loading state', () async {
expect(state.isLoading.value, equals(false));
final future = state.asyncIncrement();
expect(state.isLoading.value, equals(true));
await future;
expect(state.isLoading.value, equals(false));
expect(state.count.value, equals(1));
});
test('emits state changes in correct order', () async {
final countEmissions = <int>[];
final loadingEmissions = <bool>[];
final countSub = state.count.stream.listen(countEmissions.add);
final loadingSub = state.isLoading.stream.listen(loadingEmissions.add);
await state.asyncIncrement();
await Future.delayed(Duration.zero);
expect(countEmissions, equals([0, 1]));
expect(loadingEmissions, equals([false, true, false]));
await countSub.cancel();
await loadingSub.cancel();
});
});
Widget Testing with Reactive Components
Testing Reactive Widgets
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
class CounterWidget extends StatefulWidget {
final CounterState state;
const CounterWidget({Key? key, required this.state}) : super(key: key);
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
Widget build(BuildContext context) {
return Column(
children: [
ReactiveBuilder(
reactive: widget.state.count,
builder: (context, count) => Text('Count: $count'),
),
ReactiveBuilder(
reactive: widget.state.isLoading,
builder: (context, isLoading) => isLoading
? CircularProgressIndicator()
: SizedBox.shrink(),
),
ElevatedButton(
onPressed: widget.state.increment,
child: Text('Increment'),
),
ElevatedButton(
onPressed: widget.state.asyncIncrement,
child: Text('Async Increment'),
),
],
);
}
}
// Widget Tests
void main() {
testWidgets('displays initial count', (tester) async {
final state = CounterState();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CounterWidget(state: state),
),
),
);
expect(find.text('Count: 0'), findsOneWidget);
state.dispose();
});
testWidgets('updates UI when count changes', (tester) async {
final state = CounterState();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CounterWidget(state: state),
),
),
);
// Tap increment button
await tester.tap(find.text('Increment'));
await tester.pump();
expect(find.text('Count: 1'), findsOneWidget);
state.dispose();
});
testWidgets('shows loading indicator during async operation', (tester) async {
final state = CounterState();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: CounterWidget(state: state),
),
),
);
// Tap async increment
await tester.tap(find.text('Async Increment'));
await tester.pump();
// Should show loading
expect(find.byType(CircularProgressIndicator), findsOneWidget);
// Wait for operation to complete
await tester.pump(Duration(milliseconds: 100));
// Should hide loading
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Count: 1'), findsOneWidget);
state.dispose();
});
}
Integration Testing
End-to-End Testing
import 'package:integration_test/integration_test.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Counter App Integration Tests', () {
testWidgets('complete user flow', (tester) async {
await tester.pumpWidget(MyApp());
// Verify initial state
expect(find.text('Count: 0'), findsOneWidget);
// Increment multiple times
await tester.tap(find.text('Increment'));
await tester.pump();
expect(find.text('Count: 1'), findsOneWidget);
await tester.tap(find.text('Increment'));
await tester.pump();
expect(find.text('Count: 2'), findsOneWidget);
// Test async operation
await tester.tap(find.text('Async Increment'));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await tester.pump(Duration(milliseconds: 100));
expect(find.text('Count: 3'), findsOneWidget);
// Test decrement
await tester.tap(find.text('Decrement'));
await tester.pump();
expect(find.text('Count: 2'), findsOneWidget);
});
});
}
Mocking and Stubbing
Mock Reactive Dependencies
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';
([DataRepository])
void main() {
test('mocks reactive repository', () async {
final mockRepo = MockDataRepository();
// Stub stream response
when(mockRepo.dataStream).thenAnswer(
(_) => Stream.value(42),
);
final emissions = <int>[];
final subscription = mockRepo.dataStream.listen(emissions.add);
await Future.delayed(Duration.zero);
expect(emissions, equals([42]));
verify(mockRepo.dataStream).called(1);
await subscription.cancel();
});
}
Testing Error Handling
test('handles stream errors correctly', () async {
final source = ReactiveValue<int>(0);
final errors = <Object>[];
final subscription = source.stream.listen(
(_) {},
onError: errors.add,
);
source.addError(Exception('Test error'));
await Future.delayed(Duration.zero);
expect(errors.length, equals(1));
expect(errors.first, isA<Exception>());
await subscription.cancel();
source.dispose();
});
Performance Testing
test('handles high-frequency updates efficiently', () async {
final source = ReactiveValue<int>(0);
final stopwatch = Stopwatch()..start();
for (int i = 0; i < 10000; i++) {
source.value = i;
}
stopwatch.stop();
expect(stopwatch.elapsedMilliseconds, lessThan(100));
source.dispose();
});
Best Practices
✅ Do's
- Always dispose reactive values in
tearDown() - Use
setUp()andtearDown()for clean test isolation - Test both success and error paths
- Verify subscription cleanup to prevent memory leaks
- Use
await Future.delayed(Duration.zero)to wait for microtasks - Test state transitions in the correct order
- Mock external dependencies for unit tests
❌ Don'ts
- Don't forget to cancel subscriptions in tests
- Don't rely on timing without proper async handling
- Don't test implementation details - focus on behavior
- Don't share state between tests
- Don't skip tearDown - always clean up resources
Testing Checklist
- All reactive values are disposed in tearDown
- All subscriptions are properly cancelled
- Both success and error paths are tested
- Async operations use proper awaiting
- State transitions are verified in order
- External dependencies are mocked
- Memory leaks are prevented
- Performance is acceptable
Summary
Effective testing of reactive applications requires:
- Proper setup/teardown: Always dispose resources
- Async handling: Use proper awaiting and pumping
- Stream verification: Test emissions in order
- State management: Verify state transitions
- Error handling: Test error paths
- Mocking: Isolate units under test
- Performance: Ensure efficient stream processing
With these patterns, you can build robust, well-tested reactive applications that maintain quality and reliability throughout their lifecycle.