SignalR Testing Guide: .NET Unit Tests and Playwright Integration
SignalR hubs are straightforward to unit test by mocking IClientProxy and IHubContext, but testing the full WebSocket transport and client behavior requires integration tests. This guide covers xUnit hub tests, transport fallback testing, and Playwright WebSocket interception for end-to-end SignalR verification.
SignalR is Microsoft's real-time web framework that abstracts over WebSockets, Server-Sent Events, and long polling. It powers notification systems, live dashboards, collaborative editors, and chat in .NET applications. This guide walks through a complete testing strategy: fast xUnit unit tests for hub logic, integration tests for transport negotiation, and Playwright tests for end-to-end browser behavior.
Unit Testing SignalR Hubs with xUnit
SignalR hubs derive from Hub<T>, where T is a typed client interface. This makes them highly testable — you can mock the caller context and client proxy without a running server.
First, define the hub and its client interface:
// Hubs/ChatHub.cs
public interface IChatClient
{
Task ReceiveMessage(string user, string message);
Task UserJoined(string user);
Task Error(string message);
}
public class ChatHub : Hub<IChatClient>
{
private readonly IMessageRepository _repo;
public ChatHub(IMessageRepository repo)
{
_repo = repo;
}
public async Task SendMessage(string message)
{
if (string.IsNullOrWhiteSpace(message))
{
await Clients.Caller.Error("Message cannot be empty");
return;
}
var user = Context.User?.Identity?.Name ?? "anonymous";
await _repo.SaveAsync(user, message);
await Clients.All.ReceiveMessage(user, message);
}
public async Task JoinGroup(string groupName)
{
await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
await Clients.Group(groupName).UserJoined(Context.User?.Identity?.Name ?? "anonymous");
}
}Now write unit tests using Moq:
// Tests/ChatHubTests.cs
using Microsoft.AspNetCore.SignalR;
using Moq;
using Xunit;
public class ChatHubTests
{
private readonly Mock<IMessageRepository> _repoMock;
private readonly Mock<IHubCallerClients<IChatClient>> _clientsMock;
private readonly Mock<IChatClient> _callerMock;
private readonly Mock<IChatClient> _allClientsMock;
private readonly Mock<HubCallerContext> _contextMock;
private readonly ChatHub _hub;
public ChatHubTests()
{
_repoMock = new Mock<IMessageRepository>();
_clientsMock = new Mock<IHubCallerClients<IChatClient>>();
_callerMock = new Mock<IChatClient>();
_allClientsMock = new Mock<IChatClient>();
_contextMock = new Mock<HubCallerContext>();
_clientsMock.Setup(c => c.Caller).Returns(_callerMock.Object);
_clientsMock.Setup(c => c.All).Returns(_allClientsMock.Object);
_hub = new ChatHub(_repoMock.Object)
{
Clients = _clientsMock.Object,
Context = _contextMock.Object,
};
}
[Fact]
public async Task SendMessage_ValidMessage_BroadcastsToAll()
{
// Arrange
_contextMock.Setup(c => c.User!.Identity!.Name).Returns("alice");
_repoMock.Setup(r => r.SaveAsync("alice", "hello")).Returns(Task.CompletedTask);
_allClientsMock.Setup(c => c.ReceiveMessage("alice", "hello")).Returns(Task.CompletedTask);
// Act
await _hub.SendMessage("hello");
// Assert
_allClientsMock.Verify(c => c.ReceiveMessage("alice", "hello"), Times.Once);
_repoMock.Verify(r => r.SaveAsync("alice", "hello"), Times.Once);
}
[Fact]
public async Task SendMessage_EmptyMessage_ReturnsErrorToCaller()
{
// Arrange
_callerMock.Setup(c => c.Error(It.IsAny<string>())).Returns(Task.CompletedTask);
// Act
await _hub.SendMessage(" ");
// Assert
_callerMock.Verify(c => c.Error("Message cannot be empty"), Times.Once);
_allClientsMock.Verify(c => c.ReceiveMessage(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
[Fact]
public async Task SendMessage_DoesNotBroadcastWhenValidationFails()
{
// Arrange + Act
await _hub.SendMessage("");
// Assert — repo should not be called either
_repoMock.Verify(r => r.SaveAsync(It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
}Testing Hub Groups with IGroupManager
Group management is a common source of bugs. Test it by mocking IGroupManager:
public class GroupTests
{
[Fact]
public async Task JoinGroup_AddsConnectionToGroupAndNotifies()
{
var repoMock = new Mock<IMessageRepository>();
var clientsMock = new Mock<IHubCallerClients<IChatClient>>();
var groupsMock = new Mock<IGroupManager>();
var contextMock = new Mock<HubCallerContext>();
var groupClientMock = new Mock<IChatClient>();
contextMock.Setup(c => c.ConnectionId).Returns("conn-abc");
contextMock.Setup(c => c.User!.Identity!.Name).Returns("bob");
clientsMock.Setup(c => c.Group("dev-team")).Returns(groupClientMock.Object);
groupClientMock.Setup(c => c.UserJoined("bob")).Returns(Task.CompletedTask);
groupsMock
.Setup(g => g.AddToGroupAsync("conn-abc", "dev-team", default))
.Returns(Task.CompletedTask);
var hub = new ChatHub(repoMock.Object)
{
Clients = clientsMock.Object,
Context = contextMock.Object,
Groups = groupsMock.Object,
};
await hub.JoinGroup("dev-team");
groupsMock.Verify(g => g.AddToGroupAsync("conn-abc", "dev-team", default), Times.Once);
groupClientMock.Verify(c => c.UserJoined("bob"), Times.Once);
}
}Testing IHubContext for Server-Initiated Messages
Often you need to push messages from outside a hub — from a background service or API controller. Test this by mocking IHubContext<THub, TClient>:
// NotificationService.cs
public class NotificationService
{
private readonly IHubContext<ChatHub, IChatClient> _hubContext;
public NotificationService(IHubContext<ChatHub, IChatClient> hubContext)
{
_hubContext = hubContext;
}
public async Task NotifyUserAsync(string connectionId, string message)
{
await _hubContext.Clients.Client(connectionId).ReceiveMessage("system", message);
}
public async Task BroadcastAlertAsync(string alert)
{
await _hubContext.Clients.All.ReceiveMessage("alert", alert);
}
}
// Test
[Fact]
public async Task NotifyUser_SendsMessageToSpecificConnection()
{
var hubContextMock = new Mock<IHubContext<ChatHub, IChatClient>>();
var clientsMock = new Mock<IHubClients<IChatClient>>();
var targetClientMock = new Mock<IChatClient>();
hubContextMock.Setup(h => h.Clients).Returns(clientsMock.Object);
clientsMock.Setup(c => c.Client("conn-xyz")).Returns(targetClientMock.Object);
targetClientMock.Setup(c => c.ReceiveMessage("system", "You have a new badge"))
.Returns(Task.CompletedTask);
var service = new NotificationService(hubContextMock.Object);
await service.NotifyUserAsync("conn-xyz", "You have a new badge");
targetClientMock.Verify(
c => c.ReceiveMessage("system", "You have a new badge"),
Times.Once
);
}Integration Tests with WebApplicationFactory
For testing the full SignalR stack including HTTP negotiation and WebSocket transport:
// Tests/SignalRIntegrationTests.cs
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.SignalR.Client;
using Xunit;
public class SignalRIntegrationTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
public SignalRIntegrationTests(WebApplicationFactory<Program> factory)
{
_factory = factory;
}
private HubConnection CreateConnection()
{
var client = _factory.CreateClient();
return new HubConnectionBuilder()
.WithUrl("http://localhost/chathub", options =>
{
options.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler();
})
.Build();
}
[Fact]
public async Task SendMessage_ConnectedClients_ReceiveMessage()
{
var sender = CreateConnection();
var receiver = CreateConnection();
await sender.StartAsync();
await receiver.StartAsync();
string? receivedUser = null;
string? receivedMessage = null;
receiver.On<string, string>("ReceiveMessage", (user, msg) =>
{
receivedUser = user;
receivedMessage = msg;
});
await sender.InvokeAsync("SendMessage", "hello from sender");
await Task.Delay(500); // allow propagation
Assert.Equal("hello from sender", receivedMessage);
await sender.StopAsync();
await receiver.StopAsync();
}
}Testing Transport Fallback (Long Polling)
Verify your application handles environments where WebSockets are blocked:
[Fact]
public async Task ConnectsViaLongPollingWhenWebSocketUnavailable()
{
var connection = new HubConnectionBuilder()
.WithUrl("http://localhost/chathub", options =>
{
options.HttpMessageHandlerFactory = _ => _factory.Server.CreateHandler();
options.Transports = HttpTransportType.LongPolling; // force fallback
})
.Build();
await connection.StartAsync();
Assert.Equal(HubConnectionState.Connected, connection.State);
var tcs = new TaskCompletionSource<string>();
connection.On<string, string>("ReceiveMessage", (_, msg) => tcs.TrySetResult(msg));
await connection.InvokeAsync("SendMessage", "polling test");
var result = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("polling test", result);
await connection.StopAsync();
}Playwright WebSocket Interception for E2E Tests
Playwright can intercept WebSocket frames to verify your frontend SignalR client behavior:
// signalr.spec.ts
import { test, expect } from '@playwright/test';
test('displays incoming SignalR message in UI', async ({ page }) => {
const wsFrames: string[] = [];
// Capture WebSocket frames
page.on('websocket', ws => {
ws.on('framereceived', frame => {
if (frame.payload) wsFrames.push(frame.payload.toString());
});
});
await page.goto('http://localhost:3000/chat');
// Wait for SignalR connection
await expect(page.locator('[data-testid="connection-status"]')).toHaveText('Connected');
// Trigger a message from a second browser context
const context2 = await page.context().browser()!.newContext();
const page2 = await context2.newPage();
await page2.goto('http://localhost:3000/chat');
await page2.fill('[data-testid="message-input"]', 'Hello from page 2');
await page2.click('[data-testid="send-button"]');
// Verify it appears on page 1
await expect(page.locator('[data-testid="message-list"] li').last()).toContainText('Hello from page 2');
// Optionally verify the WebSocket frame was a SignalR message
const hasSignalRFrame = wsFrames.some(f => f.includes('"ReceiveMessage"'));
expect(hasSignalRFrame).toBe(true);
await context2.close();
});Load Testing with NBomber
NBomber is a .NET load testing framework that works well with SignalR:
// LoadTests/SignalRLoadTest.cs
using NBomber.CSharp;
using Microsoft.AspNetCore.SignalR.Client;
var connections = Enumerable.Range(0, 100).Select(_ =>
new HubConnectionBuilder()
.WithUrl("http://localhost:5000/chathub")
.Build()
).ToList();
await Task.WhenAll(connections.Select(c => c.StartAsync()));
var scenario = Scenario.Create("signalr_chat", async context =>
{
var conn = connections[context.InvocationNumber % connections.Count];
try
{
await conn.InvokeAsync("SendMessage", $"load test {context.InvocationNumber}");
return Response.Ok();
}
catch (Exception ex)
{
return Response.Fail(ex.Message);
}
})
.WithLoadSimulations(
Simulation.Inject(rate: 50, interval: TimeSpan.FromSeconds(1), during: TimeSpan.FromSeconds(30))
);
NBomberRunner.RegisterScenarios(scenario).Run();
await Task.WhenAll(connections.Select(c => c.StopAsync()));Reconnection and Disconnection Testing
Test that your application handles dropped connections correctly:
[Fact]
public async Task HubOnDisconnected_CleansUpUserPresence()
{
var presenceServiceMock = new Mock<IPresenceService>();
var contextMock = new Mock<HubCallerContext>();
contextMock.Setup(c => c.ConnectionId).Returns("conn-leaving");
contextMock.Setup(c => c.User!.Identity!.Name).Returns("charlie");
presenceServiceMock
.Setup(p => p.RemoveAsync("charlie"))
.Returns(Task.CompletedTask);
var hub = new ChatHub(Mock.Of<IMessageRepository>())
{
Context = contextMock.Object,
Clients = Mock.Of<IHubCallerClients<IChatClient>>(),
Groups = Mock.Of<IGroupManager>(),
};
await hub.OnDisconnectedAsync(null);
presenceServiceMock.Verify(p => p.RemoveAsync("charlie"), Times.Once);
}This complete testing pyramid — unit tests for hub logic with mocked clients, integration tests for transport negotiation, and Playwright for UI behavior — ensures your SignalR application is reliable from the protocol layer all the way to the browser. HelpMeTest can automate your SignalR end-to-end scenarios and alert on connection failures or message delivery regressions in staging and production environments.