Testing
Plugins are plain Dart classes, so most plugin testing is just Dart testing. The framework-specific question is: when does a test need a stub context, and when does it need a full PluginRuntime? Three questions cover almost every case.
What we test, what we don’t
Section titled “What we test, what we don’t”- Fake at your boundaries: repositories, IO clients, external APIs. Don’t fake the framework primitives (bus, registry, context, runtime); use them.
- Use the blessed factories.
PluginContext.stub(),SessionPluginContext.stub(), andGlobalPluginContext.stub()already give you a real registry and bus wired together. If you find yourself hand-constructing a context, there’s a helper. - Each test owns its setup. Don’t share a context, a registry, or a bus across tests.
StatefulPluginService.attachonly runs inside the runtime. A stub bypasses_runAttach, so the service’scontextgetter throws when accessed. Drive stateful services through a realPluginRuntime, or test their pure methods in isolation.
Does this service compute correctly?
Section titled “Does this service compute correctly?”Construct the service, inject settings if it reads any, call the method, assert. No framework primitives needed.
/// Level 1: test a PluginService in total isolation.void testNotificationServiceChannel() { final service = NotificationService(); service.injectSettings({'channel': 'slack'}, hash: 'test-1');
assert(service.channel == 'slack', 'channel should be slack');}This is the cheapest tier and where the most plugin tests should land: config readers, formatters, query builders, anything whose behavior is a pure function of its inputs.
Does it interact correctly with the bus and registry?
Section titled “Does it interact correctly with the bus and registry?”When the test cares about event subscriptions, request handlers, or how the service composes with peers in the registry, use a .stub() context. It gives you a real registry and bus without spinning up a runtime.
Inject a fake for a peer service
Section titled “Inject a fake for a peer service”The named-arg form of registerSingleton carries a priority, so a fake registered at Priority.system outranks anything the system under test contributes.
/// Demonstrates injecting a fake into [SessionPluginContext.stub]'s registry/// using the named-arg form of [ServiceRegistry.registerSingleton].void demonstrateStubInjectFake() { final ctx = SessionPluginContext.stub(); ctx.registry.registerSingleton<Logger>( pluginId: const PluginId('test'), serviceId: const ServiceId('logger'), create: () => FakeLogger(), priority: Priority.system, // beats anything the SUT registers );
final logger = ctx.registry.resolve<Logger>(const ServiceId('logger')); assert(logger is FakeLogger, 'expected FakeLogger');}Drive a plugin’s attach handlers
Section titled “Drive a plugin’s attach handlers”For plugins whose reactive logic lives in Plugin.attach (using the inherited on / onRequest helpers), wire the plugin against a stub context and emit on the stub bus.
/// Drive a plugin's [Plugin.attach] handlers against a/// [SessionPluginContext.stub] and emit on the stub's bus. No runtime/// orchestration: cheaper than `PluginRuntime` when the question is "does/// this plugin react to this event correctly?" rather than "does the/// runtime sequence things correctly?".Future<void> testPluginRecordsOnEvent() async { final ctx = SessionPluginContext.stub(); final plugin = UsernameRecorderPlugin();
plugin.register(ctx.registry.scopedFor(plugin.pluginId)); plugin.attach(ctx);
await ctx.bus.emit<UserJoined>(event: const UserJoined('alice'));
assert( plugin.recorded.single == 'alice', 'attach should have wired the handler against the stub bus', );}Plugin.attach is what the test calls directly, so handlers registered inside it land on the stub bus. Plugins that delegate reactive work to a StatefulPluginService cannot be exercised this way; jump to the next section.
Assert on cascade behavior
Section titled “Assert on cascade behavior”emit returns the post-cascade envelope. Inspect event for mutations applied by handlers, stopped for halts.
/// Asserts cascade mutation and halt via [EventEnvelope].Future<void> testAssertCascade() async { final ctx = PluginContext.stub(); ctx.bus.on<DraftMessage>((e) => e.event.text = e.event.text.toUpperCase()); final env = await ctx.bus.emit<DraftMessage>(event: DraftMessage('hi')); assert(env.event.text == 'HI', 'handler should uppercase the draft text'); assert(!env.stopped, 'no handler called stop; should not be stopped');}For request/response: bus.maybeRequest returns null on no-handler-or-all-conceded; bus.request throws a NoRequestAnswerException subtype (RequestNotWiredException or AllConcededException). Use maybeRequest to assert the absence path; reach for request only when at least one handler is guaranteed to claim.
Does the whole plugin work end-to-end?
Section titled “Does the whole plugin work end-to-end?”When the test depends on StatefulPluginService.attach, lifecycle order, settings reconciliation, or exception aggregation, use a real PluginRuntime. This is the default tier for “does my plugin work?”, not an escape hatch.
Lifecycle order
Section titled “Lifecycle order”A small helper plugin that records lifecycle calls is enough to assert order.
class TrackingPlugin extends SessionPlugin { @override final PluginId pluginId;
/// Ordered log of lifecycle calls for assertion. final List<String> calls = [];
/// Creates a [TrackingPlugin] with [pluginId]. TrackingPlugin(this.pluginId);
@override void register(ScopedServiceRegistry registry) { calls.add('register'); }
@override void attach(SessionPluginContext context) { calls.add('attach'); }
@override Future<void> detach(SessionPluginContext context) async { calls.add('detach'); }}Future<void> testPluginDisabledByUpdateSettings() async { final runtime = PluginRuntime(); final plugin = TrackingPlugin(const PluginId('toggle_me')); runtime.addPlugin(plugin); runtime.init(); final session = await runtime.createSession();
await runtime.updateSettings( const RuntimeSettings( plugins: {PluginId('toggle_me'): PluginConfig(enabled: false)}, ), );
assert( !session.isPluginEnabled(const PluginId('toggle_me')), 'plugin should be disabled', ); assert( plugin.calls.join(',') == 'register,attach,detach', 'detach must run on disable', );
await runtime.dispose();}Toggling a plugin off runs its detach. Toggling back on runs register and attach again.
Stateful services
Section titled “Stateful services”For stateful services, attach them through the runtime, exercise whatever the service reacts to, then detach to verify cleanup.
Future<void> testChatBufferRecordsMessages() async { final runtime = PluginRuntime(plugins: [ChatBufferPlugin()])..init(); final session = await runtime.createSession();
final buffer = session.resolve<ChatBuffer>(const ServiceId('buffer'));
await session.bus.emit<NewMessageEvent>(event: const NewMessageEvent('hi')); assert(buffer.messages.length == 1, 'should have 1 message after attach');
await runtime.updateSessionSettings( session, newSettings: const RuntimeSettings( plugins: {PluginId('chat'): PluginConfig(enabled: false)}, ), );
await session.bus.emit<NewMessageEvent>(event: const NewMessageEvent('bye')); assert(buffer.messages.length == 1, 'detached; subscription cancelled');
await runtime.dispose();}Resolve the service through the registry once before exercising it. That is what stamps pluginId and serviceId onto the instance.
Where failures surface
Section titled “Where failures surface”The runtime aggregates lifecycle errors into PluginLifecycleException and throws after the phase finishes. The exception carries a phase string ('attachGlobal', 'attachSession', 'detachSession', 'updateSessionSettings', etc.) and a list of (pluginId, error, stackTrace) tuples.
/// Demonstrates asserting that runtime.init throws PluginLifecycleException.void assertThrowsLifecycleException() { final runtime = PluginRuntime(plugins: [CrashingPlugin()]);
Object? caught; try { runtime.init(); } on PluginLifecycleException catch (e) { caught = e; } assert( caught is PluginLifecycleException, 'expected PluginLifecycleException', );}Future<void> testBadPluginSurfacesException() async { final runtime = PluginRuntime(); runtime.addPlugin(CrashingPlugin());
try { runtime.init(); assert(false, 'expected PluginLifecycleException'); } on PluginLifecycleException catch (e) { assert(e.phase == 'attachGlobal', 'phase should be attachGlobal'); assert( e.failures.first.$1 == const PluginId('crashing_plugin'), 'failure plugin id should match', ); } finally { if (runtime.globalBus.isDisposed == false) { await runtime.dispose(); } }}Patterns that age well
Section titled “Patterns that age well”| Pattern | Severity | Why |
|---|---|---|
| Don’t fake the bus, registry, or context. | High | They are framework primitives. Use .stub() if you want a real one with sane defaults. |
Don’t hand-construct a SessionPluginContext from ServiceRegistry() and EventBus(). | High | SessionPluginContext.stub() already does this with sane defaults. |
Don’t drive a StatefulPluginService.attach directly. | High | Its context binding only happens in the runtime’s _runAttach. Calling attach() outside a runtime leaves the service’s context getter throwing. |
| Don’t share a registry, bus, or context across tests. | Medium | Fresh state per test prevents handler leaks and priority surprises. |
| Don’t mock the framework primitives. | Medium | The bus is small, fast, and well-exercised. A real bus gives better signal than a mock. |
| Don’t assert on private state when an emitted event covers the same behavior. | Low | The event is your test contract; private state is implementation. |