Part 3: Making Testing Enjoyable (Yes, Really)
This is the third article in a series exploring Event-Component-System architecture for Flutter state management. Read Part 2 if you haven't already.
The Testing Problem Nobody Talks About
Let's be honest. Most Flutter developers don't love writing tests. Not because we don't believe in testing, but because state management solutions make testing painful.
Testing a BLoC? You're mocking streams, waiting for state emissions, and dealing with async complexity.
Testing Provider? You're wrapping everything in ChangeNotifierProvider and rebuilding widget trees.
Testing Riverpod? You're creating ProviderContainer instances and overriding providers.
The real problem isn't testing itself; it's that traditional state management couples everything together, making isolation nearly impossible.
ECS solves this by design. When data, behaviour, and communication are completely separate, testing becomes straightforward. Today, I'll show you how.
The ECS Testing Advantage
Before we dive into code, let's understand why ECS makes testing easier:
1. Systems are pure functions
- Input: Current component state + event trigger
- Output: Updated component state
- No hidden dependencies, no global state
2. Components are simple data holders
- Test them like any Dart class
- Verify notifications fire
- Check value updates
3. Events are stateless triggers
- Easy to trigger in tests
- No setup complexity
4. Features are self-contained
- Create test features with only what you need
- No need to mock the entire app
Let's see this in action with real examples.
Testing Components: The Foundation
Components are the easiest to test because they're just data holders with change notifications.
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_event_component_system/flutter_event_component_system.dart';
class UserComponent extends ECSComponent<User?> {
UserComponent() : super(null);
}
class UserListener implements ECSEntityListener {
final void Function() callback;
UserListener(this.callback);
@override
void onEntityChanged(ECSEntity entity) {
callback();
}
}
class User {
final String name;
final String email;
User({required this.name, required this.email});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is User && name == other.name && email == other.email;
@override
int get hashCode => name.hashCode ^ email.hashCode;
}
void main() {
group('UserComponent', () {
test('initializes with null value', () {
final component = UserComponent();
expect(component.value, isNull);
});
test('updates value correctly', () {
final component = UserComponent();
final user = User(name: 'John', email: 'john@example.com');
component.update(user);
expect(component.value, equals(user));
expect(component.value?.name, equals('John'));
});
test('stores previous value', () {
final component = UserComponent();
final user1 = User(name: 'John', email: 'john@example.com');
final user2 = User(name: 'Jane', email: 'jane@example.com');
component.update(user1);
component.update(user2);
expect(component.value, equals(user2));
expect(component.previous, equals(user1));
});
test('notifies listeners on update', () {
final component = UserComponent();
bool notified = false;
component.addListener(UserListener(() {
notified = true;
}));
component.update(User(name: 'John', email: 'john@example.com'));
expect(notified, isTrue);
});
test('does not notify when notify is false', () {
final component = UserComponent();
bool notified = false;
component.addListener(UserListener(() {
notified = true;
}));
component.update(
User(name: 'John', email: 'john@example.com'),
notify: false,
);
expect(notified, isFalse);
});
});
}Key Testing Patterns:
- Test initial state
- Test value updates
- Test previous value tracking
- Test listener notifications
- Test the
notify: falsebehavior
Simple, straightforward, no mocking required.
Testing Events: Triggering Actions
Events are even simpler — they're just triggers with optional data.
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_event_component_system/flutter_event_component_system.dart';
class LoginEvent extends ECSEvent {
String? email;
String? password;
void triggerWithCredentials(String emailValue, String passwordValue) {
email = emailValue;
password = passwordValue;
trigger();
}
void clearData() {
email = null;
password = null;
}
}
class LoginListener implements ECSEntityListener {
final void Function() callback;
LoginListener(this.callback);
@override
void onEntityChanged(ECSEntity entity) {
callback();
}
}
void main() {
group('LoginEvent', () {
test('initializes with null data', () {
final event = LoginEvent();
expect(event.email, isNull);
expect(event.password, isNull);
});
test('stores data when triggered with credentials', () {
final event = LoginEvent();
event.triggerWithCredentials('test@example.com', 'password123');
expect(event.email, equals('test@example.com'));
expect(event.password, equals('password123'));
});
test('clears data correctly', () {
final event = LoginEvent();
event.triggerWithCredentials('test@example.com', 'password123');
event.clearData();
expect(event.email, isNull);
expect(event.password, isNull);
});
test('notifies listeners when triggered', () {
final event = LoginEvent();
bool notified = false;
event.addListener(LoginListener(() {
notified = true;
}));
event.trigger();
expect(notified, isTrue);
});
});
}Key Patterns:
- Test data storage
- Test trigger methods
- Test data clearing
- Test listener notifications
Testing Systems: The Real Challenge
Systems contain business logic, so this is where testing gets interesting. But ECS makes it surprisingly clean.
Example: Testing a Login System
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_event_component_system/flutter_event_component_system.dart';
// Components
class AuthStateComponent extends ECSComponent<AuthState> {
AuthStateComponent() : super(AuthState.unauthenticated);
}
enum AuthState {
unauthenticated,
loading,
authenticated,
error,
}
class AuthErrorComponent extends ECSComponent<String?> {
AuthErrorComponent() : super(null);
}
// Event
class LoginEvent extends ECSEvent {
String? email;
String? password;
void triggerWithCredentials(String emailValue, String passwordValue) {
email = emailValue;
password = passwordValue;
trigger();
}
void clearData() {
email = null;
password = null;
}
}
// System
class ValidateLoginSystem extends ReactiveSystem {
@override
Set<Type> get reactsTo => {
LoginEvent,
};
@override
Set<Type> get interactsWith => {
AuthStateComponent,
AuthErrorComponent,
};
@override
void react() {
final event = getEntity<LoginEvent>();
final authState = getEntity<AuthStateComponent>();
final authError = getEntity<AuthErrorComponent>();
final email = event.email;
final password = event.password;
// Validate
if (email == null || email.isEmpty) {
authState.update(AuthState.error);
authError.update('Email is required');
event.clearData();
return;
}
if (password == null || password.length < 8) {
authState.update(AuthState.error);
authError.update('Password must be at least 8 characters');
event.clearData();
return;
}
// Validation passed
authState.update(AuthState.loading);
authError.update(null);
event.clearData();
}
}
// Test Feature
class TestLoginFeature extends ECSFeature {
TestLoginFeature() {
addEntity(AuthStateComponent());
addEntity(AuthErrorComponent());
addEntity(LoginEvent());
addSystem(ValidateLoginSystem());
}
}
void main() {
group('ValidateLoginSystem', () {
late ECSManager manager;
late TestLoginFeature feature;
setUp(() {
feature = TestLoginFeature();
manager = ECSManager(features: {feature});
manager.initialize();
});
tearDown(() {
manager.teardown();
});
test('sets error when email is empty', () {
final loginEvent = manager.getEntity<LoginEvent>();
final authState = manager.getEntity<AuthStateComponent>();
final authError = manager.getEntity<AuthErrorComponent>();
loginEvent.triggerWithCredentials('', 'password123');
expect(authState.value, equals(AuthState.error));
expect(authError.value, equals('Email is required'));
expect(loginEvent.email, isNull); // Data cleared
});
test('sets error when password is too short', () {
final loginEvent = manager.getEntity<LoginEvent>();
final authState = manager.getEntity<AuthStateComponent>();
final authError = manager.getEntity<AuthErrorComponent>();
loginEvent.triggerWithCredentials('test@example.com', 'short');
expect(authState.value, equals(AuthState.error));
expect(authError.value, contains('at least 8 characters'));
expect(loginEvent.password, isNull); // Data cleared
});
test('sets loading state when validation passes', () {
final loginEvent = manager.getEntity<LoginEvent>();
final authState = manager.getEntity<AuthStateComponent>();
final authError = manager.getEntity<AuthErrorComponent>();
loginEvent.triggerWithCredentials('test@example.com', 'password123');
expect(authState.value, equals(AuthState.loading));
expect(authError.value, isNull);
expect(loginEvent.email, isNull); // Data cleared
});
});
}What Makes This Powerful:
- No Mocking Required — The system uses real components
- Complete Isolation — Only the system under test runs
- Clear Assertions — Check component values directly
- Fast Execution — No async complexity, no widget trees
- Easy Setup — Create manager, add feature, initialize
Testing Async Operations
Let's test a system that makes API calls:
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_event_component_system/flutter_event_component_system.dart';
// Mock API Service
class MockUserApiService {
Future<User> fetchUser(String userId) async {
await Future.delayed(Duration(milliseconds: 100));
if (userId == 'error') {
throw Exception('User not found');
}
return User(id: userId, name: 'Test User', email: 'test@example.com');
}
}
class UserApiServiceComponent extends ECSComponent<MockUserApiService> {
UserApiServiceComponent(super.value);
}
class User {
final String id;
final String name;
final String email;
User({required this.id, required this.name, required this.email});
}
// Components
class UserDataComponent extends ECSComponent<User?> {
UserDataComponent() : super(null);
}
enum LoadingState { idle, running, success, error }
class UserLoadingStateComponent extends ECSComponent<LoadingState> {
UserLoadingStateComponent() : super(LoadingState.idle);
}
class UserErrorComponent extends ECSComponent<String?> {
UserErrorComponent() : super(null);
}
// Event
class FetchUserEvent extends ECSEvent {
String? userId;
void triggerWithUserId(String id) {
userId = id;
trigger();
}
void clearData() {
userId = null;
}
}
// System
class FetchUserSystem extends ReactiveSystem {
@override
Set<Type> get reactsTo => {
FetchUserEvent,
};
@override
Set<Type> get interactsWith => {
UserDataComponent,
UserLoadingStateComponent,
UserErrorComponent,
};
@override
bool get reactsIf {
final loading = getEntity<UserLoadingStateComponent>();
return loading.value != LoadingState.running;
}
@override
void react() {
final event = getEntity<FetchUserEvent>();
final userId = event.userId;
if (userId == null) return;
final loading = getEntity<UserLoadingStateComponent>();
final error = getEntity<UserErrorComponent>();
loading.update(LoadingState.running);
error.update(null);
_fetchUserAsync(userId);
event.clearData();
}
void _fetchUserAsync(String userId) async {
try {
final apiService = getEntity<UserApiServiceComponent>();
final user = await apiService.value.fetchUser(userId);
final userData = getEntity<UserDataComponent>();
final loading = getEntity<UserLoadingStateComponent>();
userData.update(user);
loading.update(LoadingState.success);
} catch (e) {
final loading = getEntity<UserLoadingStateComponent>();
final error = getEntity<UserErrorComponent>();
loading.update(LoadingState.error);
error.update(e.toString());
}
}
}
// Test Feature
class TestUserFeature extends ECSFeature {
TestUserFeature() {
addEntity(UserApiServiceComponent(MockUserApiService()));
addEntity(UserDataComponent());
addEntity(UserLoadingStateComponent());
addEntity(UserErrorComponent());
addEntity(FetchUserEvent());
addSystem(FetchUserSystem());
}
}
void main() {
group('FetchUserSystem', () {
late ECSManager manager;
late MockUserApiService mockApi;
setUp(() {
mockApi = MockUserApiService();
final feature = TestUserFeature();
manager = ECSManager(features: {feature});
manager.initialize();
});
tearDown(() {
manager.teardown()
});
test('sets loading state immediately', () {
final fetchEvent = manager.getEntity<FetchUserEvent>();
final loading = manager.getEntity<UserLoadingStateComponent>();
fetchEvent.triggerWithUserId('user123');
expect(loading.value, equals(LoadingState.running));
});
test('fetches user successfully', () async {
final fetchEvent = manager.getEntity<FetchUserEvent>();
final userData = manager.getEntity<UserDataComponent>();
final loading = manager.getEntity<UserLoadingStateComponent>();
fetchEvent.triggerWithUserId('user123');
// Wait for async operation
await Future.delayed(Duration(milliseconds: 150));
expect(loading.value, equals(LoadingState.success));
expect(userData.value, isNotNull);
expect(userData.value?.name, equals('Test User'));
});
test('handles API errors correctly', () async {
final fetchEvent = manager.getEntity<FetchUserEvent>();
final loading = manager.getEntity<UserLoadingStateComponent>();
final error = manager.getEntity<UserErrorComponent>();
fetchEvent.triggerWithUserId('error');
// Wait for async operation
await Future.delayed(Duration(milliseconds: 150));
expect(loading.value, equals(LoadingState.error));
expect(error.value, contains('User not found'));
});
test('does not fetch when already loading', () {
final fetchEvent = manager.getEntity<FetchUserEvent>();
final loading = manager.getEntity<UserLoadingStateComponent>();
// First request
fetchEvent.triggerWithUserId('user123');
expect(loading.value, equals(LoadingState.running));
// Second request should be ignored
fetchEvent.triggerWithUserId('user456');
// Still processing first request
expect(loading.value, equals(LoadingState.running));
});
});
}Key Async Testing Patterns:
- Inject mock services as components
- Test immediate state changes (loading state)
- Use
await Future.delayedfor async assertions - Test error paths
- Test
reactsIfconditions
Testing Features: Integration Tests
Feature tests verify that multiple systems work together correctly.
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_event_component_system/flutter_event_component_system.dart';
// Imagine a shopping cart feature with multiple systems
class CartItemsComponent extends ECSComponent<List<String>> {
CartItemsComponent() : super([]);
}
class CartTotalComponent extends ECSComponent<double> {
CartTotalComponent() : super(0.0);
}
class AddToCartEvent extends ECSEvent {
String? itemId;
double? price;
void triggerWithItem(String id, double itemPrice) {
itemId = id;
price = itemPrice;
trigger();
}
void clearData() {
itemId = null;
price = null;
}
}
class AddToCartSystem extends ReactiveSystem {
@override
Set<Type> get reactsTo => {AddToCartEvent};
@override
Set<Type> get interactsWith => {CartItemsComponent};
@override
void react() {
final event = getEntity<AddToCartEvent>();
final cart = getEntity<CartItemsComponent>();
if (event.itemId != null) {
final updatedCart = List<String>.from(cart.value)..add(event.itemId!);
cart.update(updatedCart);
}
event.clearData();
}
}
class RecalculateTotalSystem extends ReactiveSystem {
final Map<String, double> prices;
RecalculateTotalSystem(this.prices);
@override
Set<Type> get reactsTo => {CartItemsComponent};
@override
Set<Type> get interactsWith => {CartTotalComponent};
@override
void react() {
final cart = getEntity<CartItemsComponent>();
final total = getEntity<CartTotalComponent>();
final newTotal = cart.value.fold<double>(
0.0,
(sum, itemId) => sum + (prices[itemId] ?? 0.0),
);
total.update(newTotal);
}
}
class CartFeature extends ECSFeature {
CartFeature(Map<String, double> prices) {
addEntity(CartItemsComponent());
addEntity(CartTotalComponent());
addEntity(AddToCartEvent());
addSystem(AddToCartSystem());
addSystem(RecalculateTotalSystem(prices));
}
}
void main() {
group('CartFeature Integration', () {
late ECSManager manager;
late Map<String, double> prices;
setUp(() {
prices = {
'item1': 10.0,
'item2': 20.0,
'item3': 15.0,
};
final feature = CartFeature(prices);
manager = ECSManager(features: {feature});
manager.initialize();
});
tearDown(() {
manager.teardown();
});
test('adding item updates cart and recalculates total', () {
final addEvent = manager.getEntity<AddToCartEvent>();
final cart = manager.getEntity<CartItemsComponent>();
final total = manager.getEntity<CartTotalComponent>();
addEvent.triggerWithItem('item1', 10.0);
expect(cart.value, contains('item1'));
expect(total.value, equals(10.0));
});
test('adding multiple items accumulates total', () {
final addEvent = manager.getEntity<AddToCartEvent>();
final cart = manager.getEntity<CartItemsComponent>();
final total = manager.getEntity<CartTotalComponent>();
addEvent.triggerWithItem('item1', 10.0);
addEvent.triggerWithItem('item2', 20.0);
addEvent.triggerWithItem('item3', 15.0);
expect(cart.value.length, equals(3));
expect(total.value, equals(45.0));
});
test('total updates automatically when cart changes', () {
final cart = manager.getEntity<CartItemsComponent>();
final total = manager.getEntity<CartTotalComponent>();
// Directly update cart (simulating another system)
cart.update(['item1', 'item2']);
// Total should auto-recalculate via ReactiveSystem
expect(total.value, equals(30.0));
});
});
}Integration Test Patterns:
- Test multiple systems working together
- Verify cascade effects (one system triggers another)
- Test realistic workflows
- Ensure systems don't interfere with each other
Testing Widgets: UI Integration
Testing ECS widgets is straightforward with Flutter's testing framework.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_event_component_system/flutter_event_component_system.dart';
// Components
class CounterComponent extends ECSComponent<int> {
CounterComponent() : super(0);
}
// Event
class IncrementEvent extends ECSEvent {}
// System
class IncrementSystem extends ReactiveSystem {
IncrementSystem();
@override
Set<Type> get reactsTo => {IncrementEvent};
@override
Set<Type> get interactsWith => {CounterComponent};
@override
void react() {
final counter = getEntity<CounterComponent>();
counter.update(counter.value + 1);
}
}
// Feature
class CounterFeature extends ECSFeature {
CounterFeature() {
addEntity(CounterComponent());
addEntity(IncrementEvent());
addSystem(IncrementSystem());
}
}
// Widget
class CounterPage extends ECSWidget {
@override
Widget build(BuildContext context, ECSContext ecs) {
final counter = ecs.watch<CounterComponent>();
final incrementEvent = ecs.get<IncrementEvent>();
return Scaffold(
body: Center(
child: Text('Count: ${counter.value}'),
),
floatingActionButton: FloatingActionButton(
onPressed: incrementEvent.trigger,
child: Icon(Icons.add),
),
);
}
}
void main() {
testWidgets('CounterPage displays and updates counter', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: ECSScope(
features: {CounterFeature()},
child: CounterPage(),
),
),
);
// Initial state
expect(find.text('Count: 0'), findsOneWidget);
// Tap button
await tester.tap(find.byType(FloatingActionButton));
await tester.pump();
// Updated state
expect(find.text('Count: 1'), findsOneWidget);
// Tap again
await tester.tap(find.byType(FloatingActionButton));
await tester.pump();
expect(find.text('Count: 2'), findsOneWidget);
});
testWidgets('can access components from widget tree', (tester) async {
final feature = CounterFeature();
await tester.pumpWidget(
MaterialApp(
home: ECSScope(
features: {feature},
child: CounterPage(),
),
),
);
// Access component from the feature
final counter = feature.getEntity<CounterComponent>();
// Verify initial state
expect(counter.value, equals(0));
// Update directly
counter.update(42);
await tester.pump();
// UI should reflect change
expect(find.text('Count: 42'), findsOneWidget);
});
}Widget Testing Patterns:
- Wrap with
ECSScopeand provide test features - Use standard
testWidgetsandWidgetTester - Access components via
ECSScope.of()for verification - Test user interactions trigger correct events
- Verify UI updates when components change
Testing Best Practices
1. Use setUp and tearDown
group('MySystem', () {
late ECSManager manager;
setUp(() {
manager = ECSManager(features: {}); // Add features here
manager.initialize(); // Run all initialize systems
});
tearDown(() {
manager.teardown(); // Run all teardown systems
});
});2. Create focused test features
// Don't include unnecessary systems or components
class TestLoginFeature extends ECSFeature {
TestLoginFeature() {
// Only what this test needs
addEntity(AuthStateComponent());
addEntity(LoginEvent());
addSystem(LoginSystem());
}
}3. Mock at the component level
// Add mock service as a component
class TestFeature extends ECSFeature {
TestFeature() {
addEntity(ApiServiceComponent(MockApiService()));
// ... rest of setup
}
}Advanced: Testing reactsIf Logic
test('only reacts when user is authenticated', () {
final feature = TestFeature();
final manager = ECSManager(features: {feature});
manager.initialize();
final authState = manager.getEntity<AuthStateComponent>();
final triggerEvent = manager.getEntity<SomeEvent>();
final targetComponent = manager.getEntity<TargetComponent>();
// Not authenticated - should not react
authState.update(AuthState.unauthenticated);
final initialValue = targetComponent.value;
triggerEvent.trigger();
expect(targetComponent.value, equals(initialValue)); // No change
// Authenticate - should react
authState.update(AuthState.authenticated);
triggerEvent.trigger();
expect(targetComponent.value, isNot(equals(initialValue))); // Changed
});Testing Event Cascades
When one event triggers systems that trigger more events:
test('login success triggers profile load', () async {
final feature = TestAuthFeature();
final manager = ECSManager(features: {feature});
manager.initialize();
final loginEvent = manager.getEntity<LoginEvent>();
final profileComponent = manager.getEntity<UserProfileComponent>();
loginEvent.triggerWithCredentials('test@example.com', 'password123');
// Wait for cascade
await Future.delayed(Duration(milliseconds: 200));
// Profile should be loaded via cascade
expect(profileComponent.value, isNotNull);
expect(profileComponent.value?.name, isNotEmpty);
});Real-World Example: Testing Complete Auth Flow
group('Complete Auth Flow', () {
late ECSManager manager;
setUp(() {
final feature = AuthFeature();
manager = ECSManager(features: {feature});
manager.initialize();
});
test('successful login flow', () async {
final loginEvent = manager.getEntity<LoginEvent>();
final authState = manager.getEntity<AuthStateComponent>();
final token = manager.getEntity<AuthTokenComponent>();
final profile = manager.getEntity<UserProfileComponent>();
// Trigger login
loginEvent.triggerWithCredentials('test@example.com', 'password123');
// Should be loading
expect(authState.value, equals(AuthState.loading));
// Wait for async operations
await Future.delayed(Duration(milliseconds: 300));
// Should be authenticated
expect(authState.value, equals(AuthState.authenticated));
expect(token.value, isNotNull);
expect(profile.value, isNotNull);
expect(profile.value?.email, equals('test@example.com'));
});
test('handles invalid credentials', () async {
final loginEvent = manager.getEntity<LoginEvent>();
final authState = manager.getEntity<AuthStateComponent>();
final error = manager.getEntity<AuthErrorComponent>();
// Trigger with bad password
loginEvent.triggerWithCredentials('test@example.com', 'wrong');
// Wait for async
await Future.delayed(Duration(milliseconds: 200));
expect(authState.value, equals(AuthState.error));
expect(error.value, contains('Invalid credentials'));
});
test('logout clears all state', () {
final logoutEvent = manager.getEntity<LogoutEvent>();
final authState = manager.getEntity<AuthStateComponent>();
final token = manager.getEntity<AuthTokenComponent>();
final profile = manager.getEntity<UserProfileComponent>();
// Set up authenticated state
authState.update(AuthState.authenticated);
token.update('fake-token');
profile.update(UserProfile(name: 'Test', email: 'test@example.com'));
// Logout
logoutEvent.trigger();
// Everything should be cleared
expect(authState.value, equals(AuthState.unauthenticated));
expect(token.value, isNull);
expect(profile.value, isNull);
});
});Performance Testing
Test that systems don't execute unnecessarily:
test('system does not react when condition is false', () {
final feature = TestFeature();
final manager = ECSManager(features: {feature});
manager.initialize();
final config = manager.getEntity<ConfigComponent>();
final triggerEvent = manager.getEntity<ProcessEvent>();
final resultComponent = manager.getEntity<ResultComponent>();
// Disable processing via config
config.update(Config(processingEnabled: false));
final initialValue = resultComponent.value;
// Trigger event
triggerEvent.trigger();
// System should not have reacted
expect(resultComponent.value, equals(initialValue));
// Enable processing
config.update(Config(processingEnabled: true));
// Trigger again
triggerEvent.trigger();
// Now it should react
expect(resultComponent.value, isNot(equals(initialValue)));
});Testing with Multiple Features
Test cross-feature communication:
test('auth feature triggers notification feature', () async {
final manager = ECSManager(
features: {
AuthFeature(),
NotificationFeature(),
},
);
manager.initialize();
final loginEvent = manager.getEntity<LoginEvent>();
final notifications = manager.getEntity<NotificationQueueComponent>();
expect(notifications.value.length, equals(0));
// Login
loginEvent.triggerWithCredentials('test@example.com', 'password123');
await Future.delayed(Duration(milliseconds: 200));
// Should have welcome notification
expect(notifications.value.length, equals(1));
expect(notifications.value.first.message, contains('Welcome back'));
});Mocking Strategies
Strategy 1: Mock Services as Components
class MockUserApi implements UserApiService {
final bool shouldFail;
MockUserApi({this.shouldFail = false});
@override
Future<User> fetchUser(String id) async {
await Future.delayed(Duration(milliseconds: 50));
if (shouldFail) {
throw Exception('API Error');
}
return User(id: id, name: 'Mock User', email: 'mock@example.com');
}
}
// Pass in test
final mockApi = MockUserApi(shouldFail: false);Strategy 2: Spy Components
class SpyComponent extends ECSComponent<int> {
int updateCount = 0;
List<int> updateHistory = [];
SpyComponent() : super(0);
@override
void update(int value, {bool notify = true}) {
updateCount++;
updateHistory.add(value);
super.update(value, notify: notify);
}
}
// In test
test('system updates component exactly once', () {
final spy = SpyComponent();
feature.addEntity(spy);
// ... trigger system
expect(spy.updateCount, equals(1));
expect(spy.updateHistory, equals([expectedValue]));
});Strategy 3: Test Doubles for Complex Logic
class TestAuthService implements AuthService {
final Map<String, String> validCredentials = {
'test@example.com': 'password123',
};
@override
Future<AuthToken> login(String email, String password) async {
await Future.delayed(Duration(milliseconds: 50));
if (validCredentials[email] == password) {
return AuthToken(token: 'fake-token-${DateTime.now().millisecondsSinceEpoch}');
}
throw Exception('Invalid credentials');
}
}Test Organization
Organize tests to mirror your feature structure:
test/
features/
auth_feature/
components/
auth_state_component_test.dart
auth_token_component_test.dart
events/
login_event_test.dart
logout_event_test.dart
systems/
login_system_test.dart
logout_system_test.dart
auth_feature_integration_test.dart
cart_feature/
components/
events/
systems/
cart_feature_integration_test.dart
widgets/
login_page_test.dart
cart_page_test.dartCoverage Tips
Aim for these coverage targets:
- Components: 100% (they're simple)
- Events: 100% (they're simple)
- Systems: 90%+ (focus on business logic)
- Features: 80%+ (integration scenarios)
- Widgets: 70%+ (critical paths)
Run coverage:
flutter test --coverage
genhtml coverage/lcov.info -o coverage/html
open coverage/html/index.htmlCommon Testing Mistakes to Avoid
1. Testing Implementation Details
// ❌ Bad: Testing private method
test('_validateEmail returns true for valid email', () {
// Can't and shouldn't test private methods
});
// ✅ Good: Testing behavior
test('sets error for invalid email', () {
loginEvent.triggerWithCredentials('invalid', 'password');
expect(authError.value, contains('Invalid email'));
});2. Not Cleaning Up
// ❌ Bad: No cleanup
test('my test', () {
final manager = ECSManager();
// ... test code
// Manager not torn down
});
// ✅ Good: Proper cleanup
setUp(() {
manager = ECSManager();
manager.activate();
manager.initialize();
});
tearDown(() {
manager.teardown();
manager.deactivate();
});3. Forgetting Async Waits
// ❌ Bad: No await
test('fetches user', () {
fetchEvent.trigger();
expect(userData.value, isNotNull); // Fails - async not complete
});
// ✅ Good: Wait for async
test('fetches user', () async {
fetchEvent.trigger();
await Future.delayed(Duration(milliseconds: 100));
expect(userData.value, isNotNull);
});4. Over-Mocking
// ❌ Bad: Mocking everything
final mockComponent1 = MockComponent1();
final mockComponent2 = MockComponent2();
final mockComponent3 = MockComponent3();
// ... 10 more mocks
// ✅ Good: Use real components, mock only external dependencies
final realComponent1 = Component1();
final realComponent2 = Component2();
final mockApi = MockApiService(); // Only mock external service5. Testing Multiple Things
// ❌ Bad: One test, many assertions
test('user flow', () {
// Tests login
// Tests profile loading
// Tests settings update
// Tests logout
// If it fails, which part broke?
});
// ✅ Good: Separate tests
test('login succeeds with valid credentials', () { });
test('profile loads after login', () { });
test('settings update correctly', () { });
test('logout clears state', () { });Testing Philosophy
Here's what makes ECS testing different:
Traditional State Management:
- Mock providers/blocs
- Complex widget wrapping
- Async stream testing
- Hard to isolate
- Slow test execution
ECS Testing:
- Test pure functions (systems)
- Direct component assertions
- Simple feature setup
- Complete isolation
- Fast execution
The Result: Tests become documentation. A new developer can read your tests and understand exactly how the feature works.
Key Takeaways
Testing Components:
- Test like any Dart class
- Verify notifications
- Check value and previous tracking
Testing Events:
- Test data storage and clearing
- Verify trigger notifications
- Simple, no mocking needed
Testing Systems:
- Create test features with only what you need
- Inject mocks as components
- Test reactsIf conditions
- Use setUp/tearDown
- Wait for async with Future.delayed
Testing Features:
- Integration tests verify system interactions
- Test cascade flows
- Verify cross-feature communication
Testing Widgets:
- Standard testWidgets with ECSScope
- Test user interactions
Best Practices:
- One thing per test
- Descriptive names
- Mock only external dependencies
- Clean up with tearDown
- Test behavior, not implementation
What's Next?
In Part 4, we'll explore ECS Inspector how to profile systems with the inspector, optimize reactsIf conditions, batch updates efficiently, and scale to hundreds of components without performance degradation.
Coming soon:
- Part 4: Performance Optimization & Profiling
- Part 5: Building a Real-World E-commerce App
- Part 6: Inspector Mastery & Advanced Debugging
- Part 7: Migration Strategies from BLoC/Provider/Riverpod
Try It Yourself
The package is open source and available on github and pub:
GitHub: flutter_event_component_system
Pub: flutter_event_component_system
Challenge:
- Write component tests with previous value tracking
- Test a system with async operations
- Create an integration test with 3+ systems
- Test a widget with ECSScope
- Achieve 90%+ coverage on a feature
Share your test coverage improvements in the comments!
Questions about testing patterns, mocking strategies, or async testing? Drop them below!
Follow for Part 4: Performance Optimization Deep Dive
Built with ❤️ for the Flutter community