INTERMEDIATEtesting25 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

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() and tearDown() 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:

  1. Proper setup/teardown: Always dispose resources
  2. Async handling: Use proper awaiting and pumping
  3. Stream verification: Test emissions in order
  4. State management: Verify state transitions
  5. Error handling: Test error paths
  6. Mocking: Isolate units under test
  7. Performance: Ensure efficient stream processing

With these patterns, you can build robust, well-tested reactive applications that maintain quality and reliability throughout their lifecycle.