Skip to content

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_ExpectedResult pattern
  • Test both success and failure paths
  • Use Shouldly assertions 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

dotnet run --project src/Tests/LBS.UnitTests/LBS.UnitTests.csproj -- --verbosity detailed

Debug Specific Test

[Fact]
public async Task DebugTest()
{
    // Set breakpoint here
    var provider = new RedisPubSubProvider(...);
    // Step through
}

Check Redis State

redis-cli
> PUBSUB CHANNELS foundry:*
> SUBSCRIBE foundry:db_changes

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

  1. Run existing unit tests to verify setup
  2. Create integration test infrastructure
  3. Implement WebSocket integration tests
  4. Add load testing with NBomber
  5. Set up CI/CD pipeline for automated testing
  6. Add mutation testing for critical paths

References