Skip to content

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.

  • 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(), and GlobalPluginContext.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.attach only runs inside the runtime. A stub bypasses _runAttach, so the service’s context getter throws when accessed. Drive stateful services through a real PluginRuntime, or test their pure methods in isolation.

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.

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');
}

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.

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.

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.

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.

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.

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();
}
}
}
PatternSeverityWhy
Don’t fake the bus, registry, or context.HighThey are framework primitives. Use .stub() if you want a real one with sane defaults.
Don’t hand-construct a SessionPluginContext from ServiceRegistry() and EventBus().HighSessionPluginContext.stub() already does this with sane defaults.
Don’t drive a StatefulPluginService.attach directly.HighIts 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.MediumFresh state per test prevents handler leaks and priority surprises.
Don’t mock the framework primitives.MediumThe 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.LowThe event is your test contract; private state is implementation.