Notification System Testing Guide¶
Overview¶
This guide covers testing strategies for the LBS Real-Time Notification System across unit tests, integration tests, and end-to-end scenarios.
Test Categories¶
1. Unit Tests (LBS.UnitTests)¶
Location: src/Tests/LBS.UnitTests/Notification/
Implemented Tests¶
RedisPubSubProviderTests.cs - Constructor initialization - Channel name formatting - Database change channel naming
PayloadStrategyTests.cs - Payload type constants validation - Change type constants validation - Payload strategy options configuration
NotificationMessageTests.cs - Default message values - Message initialization - Record value equality - Subscription request handling - Unsubscribe request handling
SubscriptionResultTests.cs - Success result creation - Failure result creation - Error message handling
Running Unit Tests¶
# These are xUnit v3 tests — run them with `dotnet run` (not `dotnet test`),
# passing filter flags after a `--` separator.
# Run all notification tests
dotnet run --project src/Tests/LBS.UnitTests/LBS.UnitTests.csproj -- \
--filter-class "*Notification*"
# Run specific test class
dotnet run --project src/Tests/LBS.UnitTests/LBS.UnitTests.csproj -- \
--filter-class "*RedisPubSubProviderTests*"
# Run with coverage (coverlet collector)
dotnet run --project src/Tests/LBS.UnitTests/LBS.UnitTests.csproj -- \
--filter-class "*Notification*" --coverage
2. Integration Tests (Future)¶
Location: src/Tests/LBS.Core.Integration.Tests/Notification/
Planned Integration Tests¶
PostgreSQL Trigger Tests
[Fact]
public async Task PostgresTrigger_OnInsert_ShouldSendNotification()
{
// Arrange
await using var connection = CreateTestConnection();
var listener = new PostgresNotificationListener(connection);
var receivedNotification = new TaskCompletionSource<string>();
listener.OnNotification += (notification) =>
receivedNotification.SetResult(notification);
// Act
await connection.ExecuteAsync(
"INSERT INTO mt_doc_team (id, data) VALUES (@id, @data)",
new { id = Guid.NewGuid(), data = "{\"name\": \"Test Team\"}" });
// Assert
var notification = await receivedNotification.Task.WaitAsync(TimeSpan.FromSeconds(5));
notification.ShouldContain("mt_doc_team");
}
Redis Pub/Sub Integration Tests
[Fact]
public async Task RedisPubSub_PublishAndSubscribe_ShouldReceiveMessage()
{
// Arrange
var provider = CreateRedisPubSubProvider();
var receivedMessage = new TaskCompletionSource<INotificationMessage>();
await provider.SubscribeAsync("test-channel", msg =>
{
receivedMessage.SetResult(msg);
return Task.CompletedTask;
});
// Act
await provider.PublishAsync("test-channel", new NotificationMessage
{
Type = "Team",
Id = "team-123",
ChangeType = ChangeType.Updated
});
// Assert
var message = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(5));
message.Type.ShouldBe("Team");
message.Id.ShouldBe("team-123");
}
WebSocket Connection Tests
[Fact]
public async Task WebSocket_SubscribeToEntity_ShouldReceiveNotifications()
{
// Arrange
var client = await CreateWebSocketClient();
// Act
await client.SendAsync(new SubscriptionRequest
{
Type = "Team",
Id = "team-123"
});
// Trigger database change
await TriggerDatabaseChange("mt_doc_team", "team-123");
// Assert
var notification = await client.ReceiveNotificationAsync(timeout: TimeSpan.FromSeconds(5));
notification.Type.ShouldBe("Team");
notification.Id.ShouldBe("team-123");
}
Authorization Tests
[Fact]
public async Task Subscribe_UnauthorizedEntity_ShouldDeny()
{
// Arrange
var client = await CreateWebSocketClient(userRole: RoleDefinition.Member);
// Act
await client.SendAsync(new SubscriptionRequest
{
Type = "AdminOnlyEntity",
Id = "admin-123"
});
// Assert
var response = await client.ReceiveAsync(timeout: TimeSpan.FromSeconds(5));
response.Type.ShouldBe("subscription_error");
response.ErrorMessage.ShouldContain("Unauthorized");
}
3. End-to-End Tests¶
Scenario: Live Match Updates
[Fact]
public async Task E2E_LiveMatchUpdate_ShouldNotifyAllSubscribers()
{
// Arrange
var client1 = await CreateWebSocketClient();
var client2 = await CreateWebSocketClient();
var client3 = await CreateWebSocketClient();
await client1.SubscribeAsync("RugbyGameState", "match-456");
await client2.SubscribeAsync("RugbyGameState", "match-456");
await client3.SubscribeAsync("RugbyGameState", "match-456");
// Act - Update match score via command
await ExecuteCommand(new UpdateMatchScoreCommand
{
MatchId = "match-456",
HomeScore = 21,
AwayScore = 14
});
// Assert - All 3 clients should receive notification with full payload
var notification1 = await client1.ReceiveNotificationAsync(timeout: TimeSpan.FromSeconds(2));
var notification2 = await client2.ReceiveNotificationAsync(timeout: TimeSpan.FromSeconds(2));
var notification3 = await client3.ReceiveNotificationAsync(timeout: TimeSpan.FromSeconds(2));
notification1.PayloadType.ShouldBe(PayloadType.Full);
notification1.Data.ShouldNotBeNull();
notification2.PayloadType.ShouldBe(PayloadType.Full);
notification3.PayloadType.ShouldBe(PayloadType.Full);
}
Scenario: Collaborative Editing
[Fact]
public async Task E2E_CollaborativeEdit_ShouldNotifyOtherSessions()
{
// Arrange
var userBrowserA = await CreateWebSocketClient(userId: "user-123");
var userBrowserB = await CreateWebSocketClient(userId: "user-123");
await userBrowserA.SubscribeAsync("Team", "team-789");
await userBrowserB.SubscribeAsync("Team", "team-789");
// Act - Browser A adds player
await ExecuteCommand(new AddPlayerToTeamCommand
{
TeamId = "team-789",
PlayerId = "player-456"
});
// Assert - Browser B should receive minimal notification
var notification = await userBrowserB.ReceiveNotificationAsync(timeout: TimeSpan.FromSeconds(2));
notification.Type.ShouldBe("Team");
notification.Id.ShouldBe("team-789");
notification.PayloadType.ShouldBe(PayloadType.Minimal);
// Browser B refetches data
var updatedTeam = await userBrowserB.QueryAsync(new TeamByIdQuery { TeamId = "team-789" });
updatedTeam.Players.ShouldContain(p => p.Id == "player-456");
}
4. Load Tests (NBomber)¶
Location: src/Tests/LBS.LoadTests/Notification/
public class NotificationLoadTests
{
[Fact]
public async Task LoadTest_10KConcurrentConnections_ShouldMaintainLatency()
{
var scenario = Scenario.Create("websocket_10k_connections", async context =>
{
var client = new NotificationClient("wss://localhost:8080/ws", authToken);
await client.Connect();
client.Subscribe(new SubscriptionOptions
{
EntityType = "RugbyGameState",
EntityId = $"match-{context.ScenarioInfo.ThreadId}",
OnNotification = (notification) =>
{
// Measure latency from database change to client receipt
var latency = DateTimeOffset.UtcNow - notification.Timestamp;
context.Logger.Debug($"Latency: {latency.TotalMilliseconds}ms");
}
});
await Task.Delay(TimeSpan.FromMinutes(5)); // Keep connection open
return Response.Ok();
})
.WithLoadSimulations(
Simulation.RampingConstant(copies: 10_000, during: TimeSpan.FromMinutes(5))
);
var stats = NBomberRunner
.RegisterScenarios(scenario)
.Run();
// Assert
stats.ScenarioStats[0].Ok.Request.RPS.ShouldBeGreaterThan(100);
stats.ScenarioStats[0].Ok.Latency.Percent99.ShouldBeLessThan(100); // <100ms
}
}
Test Data Setup¶
PostgreSQL Test Database¶
-- Create test database
CREATE DATABASE lbs_test;
\c lbs_test
-- No notification trigger to install: change detection is handled in
-- application code by ContractChangeListener (a Marten IChangeListener),
-- which publishes to the Redis "db_changes" channel. No SQL trigger needed.
-- Create test data
INSERT INTO mt_doc_team (id, data) VALUES
(gen_random_uuid(), '{"name": "Test Team 1"}'),
(gen_random_uuid(), '{"name": "Test Team 2"}');
Redis Test Instance¶
# Start Redis for testing (Docker)
docker run -d -p 6380:6379 --name redis-test redis:latest
# Configure tests to use test Redis
export Redis__ConnectionString="localhost:6380"
Mocking Strategies¶
Mock Redis for Unit Tests¶
public class FakeRedis PubSubProvider : IPubSubProvider
{
private readonly Dictionary<string, List<Func<INotificationMessage, Task>>> subscriptions = new();
public async Task PublishAsync(string channel, INotificationMessage message, CancellationToken cancellationToken = default)
{
if (this.subscriptions.TryGetValue(channel, out var handlers))
{
foreach (var handler in handlers)
{
await handler(message);
}
}
}
public Task SubscribeAsync(string channel, Func<INotificationMessage, Task> handler, CancellationToken cancellationToken = default)
{
if (!this.subscriptions.ContainsKey(channel))
{
this.subscriptions[channel] = new List<Func<INotificationMessage, Task>>();
}
this.subscriptions[channel].Add(handler);
return Task.CompletedTask;
}
public Task UnsubscribeAsync(string channel, CancellationToken cancellationToken = default)
{
this.subscriptions.Remove(channel);
return Task.CompletedTask;
}
}
Mock Subscription Manager¶
public class FakeSubscriptionManager : ISubscriptionManager
{
private readonly Dictionary<string, HashSet<string>> connectionSubscriptions = new();
public Task<SubscriptionResult> SubscribeAsync(
string connectionId,
ISubscriptionRequest request,
ClaimsPrincipal user,
CancellationToken cancellationToken = default)
{
var subscriptionId = $"{request.Type}:{request.Id}";
if (!this.connectionSubscriptions.ContainsKey(connectionId))
{
this.connectionSubscriptions[connectionId] = new HashSet<string>();
}
this.connectionSubscriptions[connectionId].Add(subscriptionId);
return Task.FromResult(SubscriptionResult.Succeeded(subscriptionId));
}
// ... other methods
}
Continuous Integration¶
GitHub Actions Workflow¶
name: Notification System Tests
on:
pull_request:
paths:
- 'src/Core/LBS.Notification.**'
- 'src/Services/LBS.NotificationService/**'
- 'src/Tests/**Notification**'
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:7
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 6379:6379
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '10.0.x'
- name: Run unit tests
run: dotnet run --project src/Tests/LBS.UnitTests/LBS.UnitTests.csproj -- --filter-class "*Notification*"
- name: Run integration tests
run: dotnet run --project src/Tests/LBS.Core.Integration.Tests/LBS.Core.Integration.Tests.csproj -- --filter-class "*Notification*"
env:
ConnectionStrings__foundry: "Host=localhost;Database=postgres;Username=postgres;Password=postgres"
Redis__ConnectionString: "localhost:6379"
Test Execution Commands¶
# xUnit v3: run with `dotnet run` against a project; filter flags go after `--`.
# Run all notification unit tests
dotnet run --project src/Tests/LBS.UnitTests/LBS.UnitTests.csproj -- \
--filter-class "*Notification*"
# Run with code coverage (coverlet collector)
dotnet run --project src/Tests/LBS.UnitTests/LBS.UnitTests.csproj -- \
--filter-class "*Notification*" --coverage
# Run specific test
dotnet run --project src/Tests/LBS.UnitTests/LBS.UnitTests.csproj -- \
--filter-method "*RedisPubSubProviderTests.Constructor_WithValidOptions_ShouldInitialize*"
# Run integration tests only
dotnet run --project src/Tests/LBS.Core.Integration.Tests/LBS.Core.Integration.Tests.csproj -- \
--filter-class "*Notification*"
# Run all tests with verbose output
dotnet run --project src/Tests/LBS.UnitTests/LBS.UnitTests.csproj -- \
--filter-class "*Notification*" --verbosity detailed
Coverage Goals¶
| Component | Target Coverage | Current |
|---|---|---|
| LBS.Notification.Abstractions | 100% | 100% |
| LBS.Notification.Redis | 80% | In Progress |
| LBS.NotificationService | 70% | Pending |
Testing Best Practices¶
DO¶
- Use descriptive test names following
MethodName_Scenario_ExpectedResultpattern - Test both success and failure paths
- Use
Shouldlyassertions for better error messages - Mock external dependencies (Redis, PostgreSQL) in unit tests
- Use real dependencies in integration tests
- Test authorization scenarios thoroughly
- Measure notification latency in load tests
DON'T¶
- Don't test implementation details, test behavior
- Don't share state between tests
- Don't use hard-coded connection strings in tests
- Don't skip cleanup in integration tests
- Don't assume test execution order
Debugging Tests¶
View Test Output¶
Debug Specific Test¶
[Fact]
public async Task DebugTest()
{
// Set breakpoint here
var provider = new RedisPubSubProvider(...);
// Step through
}
Check Redis State¶
Check PostgreSQL Notifications¶
-- Listen for notifications
LISTEN "foundry:db_changes";
-- In another session, trigger change
INSERT INTO mt_doc_team (id, data) VALUES (gen_random_uuid(), '{"name": "Test"}');
-- Original session should receive notification
Next Steps¶
- Run existing unit tests to verify setup
- Create integration test infrastructure
- Implement WebSocket integration tests
- Add load testing with NBomber
- Set up CI/CD pipeline for automated testing
- Add mutation testing for critical paths