Skip to main content
Version: 5.x

Testing handlers

DarkWS.Testing runs handlers without an HTTP server, a WebSocket, or Redis. It has no test framework dependency, so it works with NUnit, xUnit, MSTest, or anything else.

dotnet add package DarkWS.Testing

Invoke an action through the pipeline​

DarkWsTestHost builds a service provider with your DarkWS registrations and invokes registered actions exactly as the server does:

using System.Text.Json;
using DarkWS.Testing;
using Microsoft.Extensions.DependencyInjection;

await using var host = new DarkWsTestHost(builder => {
builder.AddHandlersFromAssemblyContaining<MessageHandler>();
builder.Services.AddScoped<IMessageStore, FakeMessageStore>();
});

var connection = host.CreateConnection(); // anonymous
await host.InvokeAsync(connection, "message:echo",
JsonSerializer.SerializeToElement("hello"), requestId: "1");

using var response = JsonDocument.Parse(connection.SentMessages.Single());
Assert.That(response.RootElement.GetProperty("data").GetString(), Is.EqualTo("hello"));

InvokeAsync uses the real registration, authorization, JSON binding, scope initializers, action filters, error mapping, serialization, and async scope disposal. Each invocation creates a fresh message scope. The optional second constructor argument configures DarkWsOptions, for example options => options.AllowNullPayloads = true. host.Services is the root service provider.

Authenticated calls​

Pass a session to CreateConnection. Its principal must be authenticated (Identity.IsAuthenticated is true):

var user = new ClaimsPrincipal(new ClaimsIdentity([new Claim("sub", "42")], "test"));
var connection = host.CreateConnection(new AppSession("session-1", user, accountId, userId));

The session of a test connection is fixed at creation.

Broadcasts​

host.Broadcasts records every published broadcast with its target and JSON data. The real broadcaster also routes them to connections created by the host, so their SentMessages contain both responses and broadcast envelopes (id is "@"):

var sender = host.CreateConnection(aliceSession);
var receiver = host.CreateConnection(bobSession); // same account group

await host.InvokeAsync(sender, "message:send",
JsonSerializer.SerializeToElement(new { text = "hi" }), requestId: "1");

Assert.That(host.Broadcasts.Single().Action, Is.EqualTo("message:created"));
Assert.That(receiver.SentMessages, Has.Count.EqualTo(1));

Create several connections with different sessions and groups to verify targeting. Captured byte arrays are copies; treat them as read-only.

Unit-test a handler directly​

To call a handler method without the dispatcher, initialize its context from a scope:

await using var scope = host.CreateScope(connection);
var handler = new MessageHandler(new FakeMessageStore());
scope.Initialize(handler);

var result = handler.Echo("hello");
await result.WriteResultAsync(new ResponseContext(connection, "direct", new DarkWsOptions()));

scope.Services resolves constructor dependencies and registered handlers. Direct initialization sets the connection, session, cancellation, services, and broadcaster, but skips registration, authorization, scope initializers, and filters, and the action metadata is null. Use InvokeAsync when those checks matter. Keep the scope alive until you have inspected or written the result.

What the host does not cover​

The host always uses an isolated in-memory backplane, even if your configuration registers Redis. It does not run hosted services, HTTP middleware, authentication exchanges, connection lifecycle hooks, transport queues and timeouts, or frame limits. Cover those with integration tests against a real server, for example with WebApplicationFactory and DarkWS.Client.

Configure recipients before invoking concurrent actions, and await all invocations and dispose manual scopes before disposing the host.