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: false behavior

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:

  1. No Mocking Required — The system uses real components
  2. Complete Isolation — Only the system under test runs
  3. Clear Assertions — Check component values directly
  4. Fast Execution — No async complexity, no widget trees
  5. 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.delayed for async assertions
  • Test error paths
  • Test reactsIf conditions

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 ECSScope and provide test features
  • Use standard testWidgets and WidgetTester
  • 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.dart

Coverage 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.html

Common 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 service

5. 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:

  1. Write component tests with previous value tracking
  2. Test a system with async operations
  3. Create an integration test with 3+ systems
  4. Test a widget with ECSScope
  5. 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