Testing plugins
MacroDeck.Plugin.Testing runs your plugin’s own code against a fake host, with no Macro Deck
installation and no socket. The project template’s test project already references it and uses NUnit;
the package works with any test framework.
Quick start
Section titled “Quick start”using MacroDeck.Plugin.Testing;using NUnit.Framework;
public sealed class PluginIntegrationTests{ private static PluginTestHarness CreateHarness() => PluginTestHarness.Create(builder => builder .UseLocalization(Strings.LocalizationCatalog) .RegisterIntegration<PluginIntegration>());
[Test] public async Task The_example_action_writes_the_message_to_the_log() { await using var harness = CreateHarness(); await harness.InitializeIntegrationsAsync();
var outcome = await harness.Actions.ExecuteAsync( "log-message", new Dictionary<string, object?> { ["message"] = "Hello from a test" });
Assert.That(outcome.Succeeded, Is.True); Assert.That(harness.Logs.Events.Any(e => e.Message.Contains("Hello from a test")), Is.True); }}dotnet test builds the plugin, runs the log-message action the way the host would and checks both
the result and the log line.
Createbuilds but does not start. CallInitializeIntegrationsAsync()before invoking anything; an exception from an integration’sInitializeAsyncpropagates to the test.harness.Contextis the fake host. It replacesIIntegrationContext, andharness.ClockreplacesTimeProvider. Neither substitution can be turned off.- Identity is generated. Without a
PluginTestManifestthe harness writes one with a fresh, unique reverse-domain id, the nameTest Pluginand version1.0.0. Pass your own only when the id, name, version, description or icon is what you test. - Logs are collected, not forwarded. The harness replaces the Serilog pipeline,
UseMacroDeckLoggingincluded, withharness.Logs.
Testing actions
Section titled “Testing actions”var outcome = await harness.Actions.ExecuteAsync("log-message", new Dictionary<string, object?> { ["message"] = " " });
Assert.That(outcome.Succeeded, Is.False);Assert.That(outcome.Error, Is.Not.Null);Every client call returns a CapabilityInvocationOutcome: Succeeded, Error, Data (read it with
DataAs<T>()), CorrelationId and Elapsed. The harness enforces the invocation deadline but skips
concurrency limiting and idempotency replay. harness.Actions also has GetOptionsAsync,
GetActionStateAsync and GetActionIconAsync.
Testing variables
Section titled “Testing variables”await harness.Actions.ExecuteAsync("ring", new Dictionary<string, object?>());
var reading = (await harness.Variables.GetAsync("rings")).DataAs<VariableReadingDto>();Assert.That(reading!.Value.Number, Is.EqualTo(1));GetAsync and SetAsync take the local id, not the variable name. DiscoverAsync, ResolveAsync and
SubscribeAsync cover the catalog; a push-capable catalog
is attached to harness.Context.VariableValues during InitializeIntegrationsAsync.
Testing events
Section titled “Testing events”await harness.Actions.ExecuteAsync("ring", new Dictionary<string, object?>());
var published = harness.Context.Events.Published.Single();Assert.That(published.EventId, Is.EqualTo("rang"));Assert.That(published.Parameters!.Value.GetProperty("count").GetInt32(), Is.EqualTo(1));FakeEventPublisher records every Publish in order and never throws, matching the real publisher’s
fire-and-forget contract. Parameters are serialized with the protocol’s own JSON options, so Parameters
is what the host would receive.
Testing configuration
Section titled “Testing configuration”await using var harness = CreateHarness();var entry = harness.Context.Config.AddEntry("Front door");harness.Context.Config.SeedString(entry, "room", "Hallway");
await harness.InitializeIntegrationsAsync();Seed entries before InitializeIntegrationsAsync to test what the integration does with an existing
configuration; SeedSecret does the same for secrets. To drive the setup flow
itself, use harness.ConfigFlow.StartAsync, SubmitAsync and AbandonAsync.
Testing log output
Section titled “Testing log output”Assert.That(harness.Logs.WithProperty("Room", "Hallway"), Has.Count.EqualTo(1));Assert.That(harness.Logs.AtLeast(LogLevels.Warning), Is.Empty);Property values are rendered as strings. WaitForAsync waits for a line logged from background work.
Testing devices
Section titled “Testing devices”var devices = new FakeDeviceProviderContext();var provider = new LightpadProvider();
await provider.InitializeAsync(devices);var first = devices.AssignedIdOf("pad-1");await provider.InitializeAsync(devices);
Assert.That(devices.AssignedIdOf("pad-1"), Is.EqualTo(first));FakeDeviceProviderContext keeps the host’s identity rules: registering again under a known
provider-local id is the same device, and unregistering keeps the device and only takes it offline.
OpenSession hands your provider a FakeDeviceSession to push surfaces to and read interactions from.
InitializeIntegrationsAsync does not initialize device providers, so call InitializeAsync yourself.
Testing screensavers
Section titled “Testing screensavers”var screenSavers = new FakeScreenSaverProviderContext();var provider = new PhotoIntegration();
await provider.InitializeAsync(screenSavers);
Assert.That(screenSavers.ScreenSavers.ContainsKey("photos"), Is.True);Assert.That(screenSavers.Calls.Last().Kind, Is.EqualTo(ScreenSaverProviderCallKind.Register));FakeScreenSaverProviderContext keeps the host’s identity rules: registering again under a known
provider-local id replaces the screensaver, and unregistering an unknown id is a silent no-op. Calls
records every ScreenSaverProviderCall in order. harness.Context.ScreenSavers is the same fake behind a
whole harness, and harness.ScreenSaverProvider, a ScreenSaverProviderTestClient, drives the screensaver-provider
capability with GetScreenSaversAsync, the way the host reads your catalog after a reconnect. See Screensavers.
Time and waiting
Section titled “Time and waiting”harness.Clock.Advance(TimeSpan.FromSeconds(30));await Wait.UntilAsync(() => harness.Context.Events.Published.Count > 0, because: "the poll should fire");Advance the manual clock instead of sleeping. Wait.UntilAsync throws PluginTestTimeoutException at
its deadline instead of hanging.
Protocol and process tests
Section titled “Protocol and process tests”| Tool | Use it for |
|---|---|
PluginTestHarness |
Everything above: fast, in process, no socket. Start here. |
MacroDeckTestHost.HostAsync |
The real protocol over loopback, plugin in process: serialization, reconnection, cancellation, timeouts, host callback round trips. |
MacroDeckTestHost.LaunchAsync |
A built executable or packed .macroDeckPlugin: startup, graceful shutdown, environment handoff, manifest loading. Keep these few. |
PluginTestHarness.ProblemsOf(configure) returns every configuration problem without throwing.
The conformance run
Section titled “The conformance run”macrodeck-plugin test --project src/DemoMacro Deck plugin conformance report (suite 1.2.0)Plugin: com.example.demo 1.0.0Passed: 25, Failed: 0, Skipped: 24Conformant: yesThe conformance suite checks the generic plugin contract, so do not repeat it
in your own tests. Test what is specific to your plugin, with fakes only at real external boundaries
(provider APIs, the file system). Options, filters and exit codes are in
macrodeck-plugin test.
See also
Section titled “See also”- Conformance suite - every check and the report format.
- Logging and health - what the lines you assert on look like in production.
- Sample plugins - complete test projects.
- Debugging - run the plugin under an IDE against the stub or a real host.