Testing
Testing Events

Testing Events

Assert that your modules, cubits, and services fire the right events at the right time.

Overview

The testing API provides two ways to test events:

  • ModularTestScope.fireEvent + eventsOf — For integration-style tests where the scope manages the full lifecycle.
  • EventRecorder — Standalone recorder for tests that don't need the full scope.

Both rely on ModularEventBus.fire under the hood, which dispatches to the global defaultModularEventBus.

Using ModularTestScope for Events

Setup

Call listenFor<E>() after scope.setUp() to start recording a specific event type:

payment_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router_modular/testing.dart';
 
void main() {
  final scope = ModularTestScope.fresh();
 
  setUp(() {
    scope.setUp();
    scope.listenFor<PaymentProcessedEvent>();
  });
 
  tearDown(scope.tearDown);
 
  test('fires PaymentProcessedEvent on successful charge', () async {
    scope.fireEvent(PaymentProcessedEvent(amount: 100));
    await Future.delayed(Duration(milliseconds: 50));
 
    final events = scope.eventsOf<PaymentProcessedEvent>();
    expect(events.length, equals(1));
    expect(events[0].amount, equals(100));
  });
}

Multiple Event Types

Call listenFor once per type you want to track:

setUp(() {
  scope.setUp();
  scope.listenFor<OrderPlacedEvent>();
  scope.listenFor<PaymentProcessedEvent>();
});
 
test('fires both events on checkout', () async {
  scope.fireEvent(OrderPlacedEvent(orderId: 42));
  scope.fireEvent(PaymentProcessedEvent(amount: 500));
  await Future.delayed(Duration(milliseconds: 50));
 
  expect(scope.eventsOf<OrderPlacedEvent>().length, equals(1));
  expect(scope.eventsOf<PaymentProcessedEvent>().length, equals(1));
});

Clearing Between Assertions

Use clearRecordedEvents() to reset recorded events without stopping the listener:

test('records events independently per step', () async {
  scope.fireEvent(PaymentProcessedEvent(amount: 100));
  await Future.delayed(Duration(milliseconds: 50));
  expect(scope.eventsOf<PaymentProcessedEvent>().length, equals(1));
 
  scope.clearRecordedEvents();
 
  scope.fireEvent(PaymentProcessedEvent(amount: 200));
  await Future.delayed(Duration(milliseconds: 50));
  expect(scope.eventsOf<PaymentProcessedEvent>().length, equals(1));
  expect(scope.eventsOf<PaymentProcessedEvent>()[0].amount, equals(200));
});

Using EventRecorder Standalone

EventRecorder is useful when you need finer control or when your test doesn't use a full ModularTestScope:

notification_service_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router_modular/testing.dart';
 
void main() {
  late EventRecorder recorder;
 
  setUp(() {
    recorder = EventRecorder.fresh();
    recorder.listenFor<NotificationSentEvent>();
  });
 
  tearDown(() {
    recorder.dispose();
  });
 
  test('service fires NotificationSentEvent', () async {
    final service = NotificationService();
    service.send('Hello');
    await Future.delayed(Duration(milliseconds: 50));
 
    final events = recorder.eventsOf<NotificationSentEvent>();
    expect(events.length, equals(1));
    expect(events[0].message, equals('Hello'));
  });
}

EventRecorder API

MethodDescription
EventRecorder.fresh()Creates a new recorder with no active listeners
listenFor<E>({EventBus? eventBus})Starts recording events of type E
eventsOf<E>()Returns a RecordedEventList<E> snapshot
clear()Clears recorded events without stopping listeners
dispose()Cancels all listeners and clears state

eventsOf<E>() returns an empty list if listenFor<E>() was never called — no exception.

RecordedEventList

RecordedEventList<E> is the return type of eventsOf. It wraps the captured events in a first-class collection:

final events = scope.eventsOf<PaymentProcessedEvent>();
 
events.length          // number of captured events
events.isEmpty         // true if none were captured
events.isNotEmpty      // true if at least one was captured
events[0]              // access by index
events.first           // first event
events.last            // last event
events.any((e) => e.amount > 100)          // predicate check
events.where((e) => e.amount > 100)        // returns new RecordedEventList<E>
events.toList()                            // unmodifiable List<E>

Asserting Event Content

test('payment amount is correct', () async {
  scope.fireEvent(PaymentProcessedEvent(amount: 250));
  await Future.delayed(Duration(milliseconds: 50));
 
  final events = scope.eventsOf<PaymentProcessedEvent>();
 
  expect(events, isNotEmpty);
  expect(events.first.amount, equals(250));
  expect(events.any((e) => e.amount == 250), isTrue);
});

Filtering Events

test('only high-value payments are flagged', () async {
  scope.fireEvent(PaymentProcessedEvent(amount: 50));
  scope.fireEvent(PaymentProcessedEvent(amount: 500));
  scope.fireEvent(PaymentProcessedEvent(amount: 1500));
  await Future.delayed(Duration(milliseconds: 50));
 
  final highValue = scope
      .eventsOf<PaymentProcessedEvent>()
      .where((e) => e.amount >= 500);
 
  expect(highValue.length, equals(2));
});

Testing EventModule

You can test an EventModule directly by calling initState with a FakeInjector and then firing events:

auth_module_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router_modular/testing.dart';
 
class AuthModule extends EventModule {
  final List<String> navigatedTo = [];
 
  @override
  void listen() {
    on<UserLoggedInEvent>((event, context) {
      navigatedTo.add('/dashboard');
    });
  }
 
  @override
  List<ModularRoute> get routes => [
    ChildRoute('/login', child: (_, __) => Container()),
  ];
}
 
void main() {
  setUp(clearEventModuleState);
 
  test('AuthModule navigates on UserLoggedInEvent', () async {
    final module = AuthModule();
    module.initState(FakeInjector.empty());
 
    ModularEventBus.fire(UserLoggedInEvent(userId: '42'));
    await Future.delayed(Duration(milliseconds: 50));
 
    expect(module.navigatedTo, contains('/dashboard'));
 
    module.dispose();
  });
}

Testing ModularEventMixin in Widgets

For widgets that use ModularEventMixin, use widget tests with ModularTestScope:

counter_widget_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router_modular/testing.dart';
import 'package:go_router_modular/go_router_modular.dart';
 
class CounterWidget extends StatefulWidget {
  const CounterWidget({super.key});
  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}
 
class _CounterWidgetState extends State<CounterWidget>
    with ModularEventMixin {
  int count = 0;
 
  @override
  void initState() {
    super.initState();
    on<IncrementEvent>((event, _) {
      setState(() => count += event.amount);
    });
  }
 
  @override
  Widget build(BuildContext context) => Text('$count');
}
 
void main() {
  setUp(clearEventModuleState);
 
  testWidgets('counter increments on IncrementEvent', (tester) async {
    await tester.pumpWidget(
      MaterialApp(home: CounterWidget()),
    );
 
    ModularEventBus.fire(IncrementEvent(amount: 5));
    await tester.pump(Duration(milliseconds: 50));
 
    expect(find.text('5'), findsOneWidget);
  });
}

ModularEventMixin cancels all subscriptions automatically when the widget is disposed — no teardown needed.

Custom EventBus

To isolate tests from the global bus, pass a custom EventBus:

final customBus = EventBus();
recorder.listenFor<MyEvent>(eventBus: customBus);
 
customBus.fire(MyEvent());  // only this recorder captures it

This is useful when testing modules configured with their own EventBus.