Skip to content

Integration Testing in Momentum ​

Integration tests verify that different components of your Momentum application work correctly together. They test the complete workflow from HTTP requests to database operations and event publishing.

Overview ​

Integration tests in Momentum applications typically cover:

  • End-to-end API workflows: From HTTP request to response
  • Database operations: Real database interactions with transactions
  • Event publishing: Integration events being published to Kafka
  • Service interactions: Communication between different services
  • Authentication and authorization: Security workflows

Test Infrastructure Setup ​

Using Testcontainers ​

Momentum applications use Testcontainers to provide real infrastructure for testing:

csharp
public class IntegrationTestFixture : IAsyncLifetime
{
    private readonly PostgreSqlContainer _dbContainer;
    private readonly KafkaContainer _kafkaContainer;
    private WebApplicationFactory<Program> _factory = default!;

    public IntegrationTestFixture()
    {
        _dbContainer = new PostgreSqlBuilder()
            .WithDatabase("test_db")
            .WithUsername("test_user")
            .WithPassword("test_password")
            .WithCleanUp(true)
            .Build();

        _kafkaContainer = new KafkaBuilder()
            .WithCleanUp(true)
            .Build();
    }

    public HttpClient HttpClient { get; private set; } = default!;
    public IServiceProvider Services { get; private set; } = default!;
    public string ConnectionString => _dbContainer.GetConnectionString();
    public string KafkaBootstrapServers => _kafkaContainer.GetBootstrapAddress();

    public async Task InitializeAsync()
    {
        // Start containers
        await _dbContainer.StartAsync();
        await _kafkaContainer.StartAsync();

        // Create test application
        _factory = new WebApplicationFactory<Program>()
            .WithWebHostBuilder(builder =>
            {
                builder.ConfigureAppConfiguration((context, config) =>
                {
                    config.AddInMemoryCollection(new Dictionary<string, string?>
                    {
                        ["ConnectionStrings:DefaultConnection"] = ConnectionString,
                        ["Kafka:BootstrapServers"] = KafkaBootstrapServers,
                        ["Kafka:GroupId"] = "test-consumer-group"
                    });
                });

                builder.ConfigureServices(services =>
                {
                    // Override production services for testing
                    services.RemoveAll<IEmailService>();
                    services.AddScoped<IEmailService, TestEmailService>();
                });
            });

        HttpClient = _factory.CreateClient();
        Services = _factory.Services;

        // Run database migrations
        await RunMigrationsAsync();

        // Wait for Kafka to be ready
        await WaitForKafkaAsync();
    }

    public async Task DisposeAsync()
    {
        HttpClient?.Dispose();
        await _factory?.DisposeAsync()!;
        await _dbContainer.StopAsync();
        await _kafkaContainer.StopAsync();
    }

    private async Task RunMigrationsAsync()
    {
        using var scope = Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDomainDb>();
        await db.Database.MigrateAsync();
    }

    private async Task WaitForKafkaAsync()
    {
        using var scope = Services.CreateScope();
        var producer = scope.ServiceProvider.GetRequiredService<IProducer<Null, string>>();

        // Test Kafka connectivity
        var retries = 10;
        while (retries > 0)
        {
            try
            {
                var metadata = producer.GetMetadata(TimeSpan.FromSeconds(5));
                if (metadata.Brokers.Any())
                    break;
            }
            catch
            {
                // Kafka not ready yet
            }

            retries--;
            await Task.Delay(1000);
        }
    }
}

Test Email Service ​

Create mock services for testing:

csharp
public class TestEmailService : IEmailService
{
    private readonly List<EmailMessage> _sentEmails = new();

    public IReadOnlyList<EmailMessage> SentEmails => _sentEmails.AsReadOnly();

    public Task SendEmailAsync(string to, string subject, string body, CancellationToken cancellationToken = default)
    {
        _sentEmails.Add(new EmailMessage(to, subject, body, DateTime.UtcNow));
        return Task.CompletedTask;
    }

    public Task SendWelcomeEmailAsync(string email, string name, CancellationToken cancellationToken = default)
    {
        return SendEmailAsync(email, "Welcome!", $"Welcome {name}!", cancellationToken);
    }

    public void ClearSentEmails() => _sentEmails.Clear();
}

public record EmailMessage(string To, string Subject, string Body, DateTime SentAt);

API Integration Tests ​

Basic CRUD Operations ​

csharp
public class CashierApiTests : IClassFixture<IntegrationTestFixture>
{
    private readonly IntegrationTestFixture _fixture;
    private readonly HttpClient _client;

    public CashierApiTests(IntegrationTestFixture fixture)
    {
        _fixture = fixture;
        _client = fixture.HttpClient;
    }

    [Fact]
    public async Task CreateCashier_ValidData_ReturnsCreatedResponse()
    {
        // Arrange
        var tenantId = Guid.NewGuid();
        var createRequest = new CreateCashierRequest(
            tenantId,
            "John Doe",
            "john@example.com");

        // Act
        var response = await _client.PostAsJsonAsync("/api/cashiers", createRequest);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);

        var createdCashier = await response.Content.ReadFromJsonAsync<Cashier>();
        createdCashier.Should().NotBeNull();
        createdCashier!.TenantId.Should().Be(tenantId);
        createdCashier.Name.Should().Be("John Doe");
        createdCashier.Email.Should().Be("john@example.com");
        createdCashier.Id.Should().NotBeEmpty();

        // Verify Location header
        response.Headers.Location.Should().NotBeNull();
        response.Headers.Location!.ToString().Should()
            .Contain($"/api/cashiers/{createdCashier.Id}");
    }

    [Fact]
    public async Task GetCashier_ExistingCashier_ReturnsOkResponse()
    {
        // Arrange - Create a cashier first
        var tenantId = Guid.NewGuid();
        var createdCashier = await CreateTestCashierAsync(tenantId, "Jane Doe", "jane@example.com");

        // Act
        var response = await _client.GetAsync(
            $"/api/cashiers/{createdCashier.Id}?tenantId={tenantId}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var cashier = await response.Content.ReadFromJsonAsync<Cashier>();
        cashier.Should().NotBeNull();
        cashier!.Id.Should().Be(createdCashier.Id);
        cashier.TenantId.Should().Be(tenantId);
        cashier.Name.Should().Be("Jane Doe");
        cashier.Email.Should().Be("jane@example.com");
    }

    [Fact]
    public async Task GetCashier_NonExistentCashier_ReturnsNotFound()
    {
        // Arrange
        var tenantId = Guid.NewGuid();
        var cashierId = Guid.NewGuid();

        // Act
        var response = await _client.GetAsync(
            $"/api/cashiers/{cashierId}?tenantId={tenantId}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);

        var errorResponse = await response.Content.ReadAsStringAsync();
        errorResponse.Should().Contain("Cashier not found");
    }

    [Fact]
    public async Task UpdateCashier_ValidData_ReturnsOkResponse()
    {
        // Arrange
        var tenantId = Guid.NewGuid();
        var createdCashier = await CreateTestCashierAsync(tenantId, "Original Name", "original@example.com");

        var updateRequest = new UpdateCashierRequest("Updated Name", "updated@example.com");

        // Act
        var response = await _client.PutAsJsonAsync(
            $"/api/cashiers/{createdCashier.Id}?tenantId={tenantId}",
            updateRequest);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var updatedCashier = await response.Content.ReadFromJsonAsync<Cashier>();
        updatedCashier.Should().NotBeNull();
        updatedCashier!.Id.Should().Be(createdCashier.Id);
        updatedCashier.Name.Should().Be("Updated Name");
        updatedCashier.Email.Should().Be("updated@example.com");
        updatedCashier.UpdatedDate.Should().BeAfter(createdCashier.UpdatedDate);
    }

    [Fact]
    public async Task DeleteCashier_ExistingCashier_ReturnsNoContent()
    {
        // Arrange
        var tenantId = Guid.NewGuid();
        var createdCashier = await CreateTestCashierAsync(tenantId, "To Delete", "delete@example.com");

        // Act
        var response = await _client.DeleteAsync(
            $"/api/cashiers/{createdCashier.Id}?tenantId={tenantId}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.NoContent);

        // Verify it's actually deleted
        var getResponse = await _client.GetAsync(
            $"/api/cashiers/{createdCashier.Id}?tenantId={tenantId}");
        getResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest);
    }

    [Fact]
    public async Task CreateCashier_InvalidData_ReturnsBadRequest()
    {
        // Arrange
        var createRequest = new CreateCashierRequest(
            Guid.Empty,        // Invalid tenant ID
            "",               // Invalid name
            "invalid-email"); // Invalid email

        // Act
        var response = await _client.PostAsJsonAsync("/api/cashiers", createRequest);

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.BadRequest);

        var errorContent = await response.Content.ReadAsStringAsync();
        errorContent.Should().Contain("TenantId");
        errorContent.Should().Contain("Name");
        errorContent.Should().Contain("Email");
    }

    private async Task<Cashier> CreateTestCashierAsync(Guid tenantId, string name, string email)
    {
        var createRequest = new CreateCashierRequest(tenantId, name, email);
        var response = await _client.PostAsJsonAsync("/api/cashiers", createRequest);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Cashier>()
               ?? throw new InvalidOperationException("Failed to create test cashier");
    }
}

Pagination and Search Tests ​

csharp
public class CashierListApiTests : IClassFixture<IntegrationTestFixture>
{
    private readonly IntegrationTestFixture _fixture;
    private readonly HttpClient _client;

    public CashierListApiTests(IntegrationTestFixture fixture)
    {
        _fixture = fixture;
        _client = fixture.HttpClient;
    }

    [Fact]
    public async Task GetCashiers_WithPagination_ReturnsPagedResults()
    {
        // Arrange
        var tenantId = Guid.NewGuid();

        // Create test data
        var cashiers = new[]
        {
            await CreateTestCashierAsync(tenantId, "Alice Johnson", "alice@example.com"),
            await CreateTestCashierAsync(tenantId, "Bob Smith", "bob@example.com"),
            await CreateTestCashierAsync(tenantId, "Charlie Brown", "charlie@example.com")
        };

        // Act
        var response = await _client.GetAsync(
            $"/api/cashiers?tenantId={tenantId}&page=1&pageSize=2");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var pagedResult = await response.Content.ReadFromJsonAsync<PagedResult<Cashier>>();
        pagedResult.Should().NotBeNull();
        pagedResult!.Items.Should().HaveCount(2);
        pagedResult.TotalCount.Should().Be(3);
        pagedResult.Page.Should().Be(1);
        pagedResult.PageSize.Should().Be(2);
        pagedResult.TotalPages.Should().Be(2);

        // Should be ordered by name
        pagedResult.Items[0].Name.Should().Be("Alice Johnson");
        pagedResult.Items[1].Name.Should().Be("Bob Smith");
    }

    [Fact]
    public async Task SearchCashiers_WithSearchTerm_ReturnsMatchingResults()
    {
        // Arrange
        var tenantId = Guid.NewGuid();

        await CreateTestCashierAsync(tenantId, "John Doe", "john@example.com");
        await CreateTestCashierAsync(tenantId, "Jane Smith", "jane@example.com");
        await CreateTestCashierAsync(tenantId, "Johnny Walker", "johnny@example.com");

        // Act - Search by name
        var response = await _client.GetAsync(
            $"/api/cashiers/search?tenantId={tenantId}&searchTerm=john");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);

        var searchResults = await response.Content.ReadFromJsonAsync<List<Cashier>>();
        searchResults.Should().NotBeNull();
        searchResults.Should().HaveCount(2);
        searchResults.Should().Contain(c => c.Name == "John Doe");
        searchResults.Should().Contain(c => c.Name == "Johnny Walker");
        searchResults.Should().NotContain(c => c.Name == "Jane Smith");
    }

    private async Task<Cashier> CreateTestCashierAsync(Guid tenantId, string name, string email)
    {
        var createRequest = new CreateCashierRequest(tenantId, name, email);
        var response = await _client.PostAsJsonAsync("/api/cashiers", createRequest);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Cashier>()
               ?? throw new InvalidOperationException("Failed to create test cashier");
    }
}

Database Integration Tests ​

Direct Database Testing ​

csharp
public class CashierDatabaseTests : IClassFixture<IntegrationTestFixture>
{
    private readonly IntegrationTestFixture _fixture;

    public CashierDatabaseTests(IntegrationTestFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task CreateCashier_ValidData_PersistsToDatabase()
    {
        // Arrange
        using var scope = _fixture.Services.CreateScope();
        var messageBus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
        var db = scope.ServiceProvider.GetRequiredService<AppDomainDb>();

        var tenantId = Guid.NewGuid();
        var command = new CreateCashierCommand(tenantId, "Database Test", "db@example.com");

        // Act
        var (result, integrationEvent) = await messageBus.InvokeAsync(command);

        // Assert
        result.IsSuccess.Should().BeTrue();
        result.Value.Should().NotBeNull();

        // Verify in database
        var cashier = await db.Cashiers
            .FirstOrDefaultAsync(c => c.CashierId == result.Value.Id);

        cashier.Should().NotBeNull();
        cashier!.TenantId.Should().Be(tenantId);
        cashier.Name.Should().Be("Database Test");
        cashier.Email.Should().Be("db@example.com");
        cashier.CreatedDateUtc.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1));
        cashier.UpdatedDateUtc.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1));

        // Verify integration event
        integrationEvent.Should().NotBeNull();
        integrationEvent!.Cashier.Id.Should().Be(result.Value.Id);
    }

    [Fact]
    public async Task UpdateCashier_ExistingCashier_UpdatesDatabase()
    {
        // Arrange
        using var scope = _fixture.Services.CreateScope();
        var messageBus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
        var db = scope.ServiceProvider.GetRequiredService<AppDomainDb>();

        var tenantId = Guid.NewGuid();

        // Create initial cashier
        var createCommand = new CreateCashierCommand(tenantId, "Original Name", "original@example.com");
        var (createResult, _) = await messageBus.InvokeAsync(createCommand);
        createResult.IsSuccess.Should().BeTrue();

        var originalUpdateDate = createResult.Value.UpdatedDate;

        // Wait to ensure update timestamp is different
        await Task.Delay(100);

        // Act - Update cashier
        var updateCommand = new UpdateCashierCommand(
            tenantId,
            createResult.Value.Id,
            "Updated Name",
            "updated@example.com");

        var (updateResult, updateEvent) = await messageBus.InvokeAsync(updateCommand);

        // Assert
        updateResult.IsSuccess.Should().BeTrue();

        // Verify in database
        var updatedCashier = await db.Cashiers
            .FirstOrDefaultAsync(c => c.CashierId == createResult.Value.Id);

        updatedCashier.Should().NotBeNull();
        updatedCashier!.Name.Should().Be("Updated Name");
        updatedCashier.Email.Should().Be("updated@example.com");
        updatedCashier.UpdatedDateUtc.Should().BeAfter(originalUpdateDate);

        // Verify event
        updateEvent.Should().NotBeNull();
        updateEvent!.Cashier.Name.Should().Be("Updated Name");
    }

    [Fact]
    public async Task DeleteCashier_ExistingCashier_RemovesFromDatabase()
    {
        // Arrange
        using var scope = _fixture.Services.CreateScope();
        var messageBus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
        var db = scope.ServiceProvider.GetRequiredService<AppDomainDb>();

        var tenantId = Guid.NewGuid();

        // Create cashier to delete
        var createCommand = new CreateCashierCommand(tenantId, "To Delete", "delete@example.com");
        var (createResult, _) = await messageBus.InvokeAsync(createCommand);
        createResult.IsSuccess.Should().BeTrue();

        // Act
        var deleteCommand = new DeleteCashierCommand(tenantId, createResult.Value.Id);
        var (deleteResult, deleteEvent) = await messageBus.InvokeAsync(deleteCommand);

        // Assert
        deleteResult.IsSuccess.Should().BeTrue();
        deleteResult.Value.Should().BeTrue();

        // Verify deletion in database
        var deletedCashier = await db.Cashiers
            .FirstOrDefaultAsync(c => c.CashierId == createResult.Value.Id);

        deletedCashier.Should().BeNull();

        // Verify event
        deleteEvent.Should().NotBeNull();
        deleteEvent!.CashierId.Should().Be(createResult.Value.Id);
    }

    [Fact]
    public async Task CreateCashier_DuplicateEmail_ThrowsException()
    {
        // Arrange
        using var scope = _fixture.Services.CreateScope();
        var messageBus = scope.ServiceProvider.GetRequiredService<IMessageBus>();

        var tenantId = Guid.NewGuid();
        var email = "duplicate@example.com";

        // Create first cashier
        var firstCommand = new CreateCashierCommand(tenantId, "First User", email);
        var (firstResult, _) = await messageBus.InvokeAsync(firstCommand);
        firstResult.IsSuccess.Should().BeTrue();

        // Act & Assert
        var secondCommand = new CreateCashierCommand(tenantId, "Second User", email);

        // Should throw due to unique constraint on email
        var exception = await Assert.ThrowsAsync<PostgresException>(() =>
            messageBus.InvokeAsync(secondCommand));

        exception.SqlState.Should().Be("23505"); // Unique violation
    }
}

Transaction Testing ​

csharp
public class TransactionIntegrationTests : IClassFixture<IntegrationTestFixture>
{
    private readonly IntegrationTestFixture _fixture;

    public TransactionIntegrationTests(IntegrationTestFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task ComplexOperation_PartialFailure_RollsBackTransaction()
    {
        // Arrange
        using var scope = _fixture.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDomainDb>();

        var tenantId = Guid.NewGuid();

        // Act & Assert
        await using var transaction = await db.BeginTransactionAsync();

        try
        {
            // Step 1: Create cashier (should succeed)
            var cashier = new Data.Entities.Cashier
            {
                TenantId = tenantId,
                CashierId = Guid.CreateVersion7(),
                Name = "Transaction Test",
                Email = "transaction@example.com",
                CreatedDateUtc = DateTime.UtcNow,
                UpdatedDateUtc = DateTime.UtcNow
            };

            var insertedCashier = await db.Cashiers.InsertWithOutputAsync(cashier);
            insertedCashier.Should().NotBeNull();

            // Verify cashier was inserted (within transaction)
            var foundCashier = await db.Cashiers
                .FirstOrDefaultAsync(c => c.CashierId == insertedCashier.CashierId);
            foundCashier.Should().NotBeNull();

            // Step 2: Force an error (simulating business rule failure)
            throw new InvalidOperationException("Simulated business rule failure");
        }
        catch (InvalidOperationException)
        {
            await transaction.RollbackAsync();
        }

        // Verify rollback - cashier should not exist
        var rolledBackCashier = await db.Cashiers
            .FirstOrDefaultAsync(c => c.TenantId == tenantId);
        rolledBackCashier.Should().BeNull();
    }

    [Fact]
    public async Task ComplexOperation_Success_CommitsTransaction()
    {
        // Arrange
        using var scope = _fixture.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDomainDb>();

        var tenantId = Guid.NewGuid();

        // Act
        await using var transaction = await db.BeginTransactionAsync();

        // Step 1: Create cashier
        var cashier = new Data.Entities.Cashier
        {
            TenantId = tenantId,
            CashierId = Guid.CreateVersion7(),
            Name = "Transaction Success",
            Email = "success@example.com",
            CreatedDateUtc = DateTime.UtcNow,
            UpdatedDateUtc = DateTime.UtcNow
        };

        var insertedCashier = await db.Cashiers.InsertWithOutputAsync(cashier);

        // Step 2: Create related data (simulate complex operation)
        var metadata = new Data.Entities.CashierMetadata
        {
            CashierId = insertedCashier.CashierId,
            Department = "Sales",
            HireDate = DateTime.UtcNow,
            IsActive = true
        };

        await db.CashierMetadata.InsertAsync(metadata);

        await transaction.CommitAsync();

        // Assert - Verify both entities were committed
        var persistedCashier = await db.Cashiers
            .FirstOrDefaultAsync(c => c.CashierId == insertedCashier.CashierId);
        persistedCashier.Should().NotBeNull();

        var persistedMetadata = await db.CashierMetadata
            .FirstOrDefaultAsync(m => m.CashierId == insertedCashier.CashierId);
        persistedMetadata.Should().NotBeNull();
    }
}

Event Integration Tests ​

Kafka Event Publishing Tests ​

csharp
public class EventPublishingTests : IClassFixture<IntegrationTestFixture>
{
    private readonly IntegrationTestFixture _fixture;

    public EventPublishingTests(IntegrationTestFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task CreateCashier_Success_PublishesIntegrationEvent()
    {
        // Arrange
        using var scope = _fixture.Services.CreateScope();
        var messageBus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
        var eventCapture = scope.ServiceProvider.GetRequiredService<TestEventCapture>();

        var tenantId = Guid.NewGuid();
        var command = new CreateCashierCommand(tenantId, "Event Test", "event@example.com");

        // Act
        var (result, integrationEvent) = await messageBus.InvokeAsync(command);

        // Assert
        result.IsSuccess.Should().BeTrue();
        integrationEvent.Should().NotBeNull();

        // Wait for event to be published
        await Task.Delay(1000);

        // Verify event was captured
        var capturedEvents = eventCapture.GetEvents<CashierCreated>();
        capturedEvents.Should().HaveCount(1);

        var capturedEvent = capturedEvents.First();
        capturedEvent.TenantId.Should().Be(tenantId);
        capturedEvent.Cashier.Name.Should().Be("Event Test");
        capturedEvent.Cashier.Email.Should().Be("event@example.com");
    }

    [Fact]
    public async Task UpdateCashier_Success_PublishesUpdateEvent()
    {
        // Arrange
        using var scope = _fixture.Services.CreateScope();
        var messageBus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
        var eventCapture = scope.ServiceProvider.GetRequiredService<TestEventCapture>();

        var tenantId = Guid.NewGuid();

        // Create cashier first
        var createCommand = new CreateCashierCommand(tenantId, "Original", "original@example.com");
        var (createResult, _) = await messageBus.InvokeAsync(createCommand);
        createResult.IsSuccess.Should().BeTrue();

        eventCapture.Clear(); // Clear create events

        // Act - Update cashier
        var updateCommand = new UpdateCashierCommand(
            tenantId,
            createResult.Value.Id,
            "Updated",
            "updated@example.com");

        var (updateResult, updateEvent) = await messageBus.InvokeAsync(updateCommand);

        // Assert
        updateResult.IsSuccess.Should().BeTrue();
        updateEvent.Should().NotBeNull();

        // Wait for event to be published
        await Task.Delay(1000);

        // Verify update event was captured
        var capturedEvents = eventCapture.GetEvents<CashierUpdated>();
        capturedEvents.Should().HaveCount(1);

        var capturedEvent = capturedEvents.First();
        capturedEvent.TenantId.Should().Be(tenantId);
        capturedEvent.Cashier.Name.Should().Be("Updated");
        capturedEvent.Cashier.Email.Should().Be("updated@example.com");
    }
}

// Test event capture service
public class TestEventCapture
{
    private readonly List<object> _capturedEvents = new();

    public void CaptureEvent(object evt)
    {
        _capturedEvents.Add(evt);
    }

    public List<T> GetEvents<T>() where T : class
    {
        return _capturedEvents.OfType<T>().ToList();
    }

    public void Clear()
    {
        _capturedEvents.Clear();
    }
}

Event Handler Tests ​

csharp
public class EventHandlerIntegrationTests : IClassFixture<IntegrationTestFixture>
{
    private readonly IntegrationTestFixture _fixture;

    public EventHandlerIntegrationTests(IntegrationTestFixture fixture)
    {
        _fixture = fixture;
    }

    [Fact]
    public async Task CashierCreated_Handler_SendsWelcomeEmail()
    {
        // Arrange
        using var scope = _fixture.Services.CreateScope();
        var messageBus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
        var testEmailService = scope.ServiceProvider.GetRequiredService<IEmailService>() as TestEmailService;
        testEmailService.Should().NotBeNull();

        var tenantId = Guid.NewGuid();
        var command = new CreateCashierCommand(tenantId, "Email Test", "email@example.com");

        // Act
        var (result, integrationEvent) = await messageBus.InvokeAsync(command);

        // Wait for event handlers to process
        await Task.Delay(2000);

        // Assert
        result.IsSuccess.Should().BeTrue();

        // Verify email was sent
        testEmailService!.SentEmails.Should().HaveCount(1);
        var sentEmail = testEmailService.SentEmails.First();
        sentEmail.To.Should().Be("email@example.com");
        sentEmail.Subject.Should().Be("Welcome!");
        sentEmail.Body.Should().Contain("Email Test");
    }

    [Fact]
    public async Task MultipleEvents_HandlersProcessCorrectly()
    {
        // Arrange
        using var scope = _fixture.Services.CreateScope();
        var messageBus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
        var testEmailService = scope.ServiceProvider.GetRequiredService<IEmailService>() as TestEmailService;

        var tenantId = Guid.NewGuid();

        // Act - Create multiple cashiers
        var commands = new[]
        {
            new CreateCashierCommand(tenantId, "User 1", "user1@example.com"),
            new CreateCashierCommand(tenantId, "User 2", "user2@example.com"),
            new CreateCashierCommand(tenantId, "User 3", "user3@example.com")
        };

        var results = new List<(Result<Cashier> result, CashierCreated? evt)>();
        foreach (var command in commands)
        {
            var result = await messageBus.InvokeAsync(command);
            results.Add(result);
        }

        // Wait for all event handlers to process
        await Task.Delay(3000);

        // Assert
        results.Should().AllSatisfy(r => r.result.IsSuccess.Should().BeTrue());

        // Verify all emails were sent
        testEmailService!.SentEmails.Should().HaveCount(3);
        testEmailService.SentEmails.Should().Contain(e => e.To == "user1@example.com");
        testEmailService.SentEmails.Should().Contain(e => e.To == "user2@example.com");
        testEmailService.SentEmails.Should().Contain(e => e.To == "user3@example.com");
    }
}

Authentication and Authorization Tests ​

JWT Token Testing ​

csharp
public class AuthenticationIntegrationTests : IClassFixture<IntegrationTestFixture>
{
    private readonly IntegrationTestFixture _fixture;
    private readonly HttpClient _client;

    public AuthenticationIntegrationTests(IntegrationTestFixture fixture)
    {
        _fixture = fixture;
        _client = fixture.HttpClient;
    }

    [Fact]
    public async Task ProtectedEndpoint_WithoutToken_ReturnsUnauthorized()
    {
        // Arrange
        var tenantId = Guid.NewGuid();

        // Act
        var response = await _client.GetAsync($"/api/cashiers/{Guid.NewGuid()}?tenantId={tenantId}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
    }

    [Fact]
    public async Task ProtectedEndpoint_WithValidToken_ReturnsOk()
    {
        // Arrange
        var tenantId = Guid.NewGuid();
        var cashier = await CreateTestCashierWithAuthAsync(tenantId, "Auth Test", "auth@example.com");

        var token = GenerateTestJwtToken(tenantId, "test-user");
        _client.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);

        // Act
        var response = await _client.GetAsync($"/api/cashiers/{cashier.Id}?tenantId={tenantId}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
    }

    [Fact]
    public async Task ProtectedEndpoint_WithWrongTenant_ReturnsForbidden()
    {
        // Arrange
        var tenantId1 = Guid.NewGuid();
        var tenantId2 = Guid.NewGuid();

        var cashier = await CreateTestCashierWithAuthAsync(tenantId1, "Tenant Test", "tenant@example.com");

        // Create token for different tenant
        var token = GenerateTestJwtToken(tenantId2, "test-user");
        _client.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);

        // Act
        var response = await _client.GetAsync($"/api/cashiers/{cashier.Id}?tenantId={tenantId1}");

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
    }

    private string GenerateTestJwtToken(Guid tenantId, string userId)
    {
        var tokenHandler = new JwtSecurityTokenHandler();
        var key = Encoding.ASCII.GetBytes("test-secret-key-that-is-long-enough-for-hmac");

        var tokenDescriptor = new SecurityTokenDescriptor
        {
            Subject = new ClaimsIdentity(new[]
            {
                new Claim("sub", userId),
                new Claim("tenant_id", tenantId.ToString()),
                new Claim("role", "user")
            }),
            Expires = DateTime.UtcNow.AddHours(1),
            SigningCredentials = new SigningCredentials(
                new SymmetricSecurityKey(key),
                SecurityAlgorithms.HmacSha256Signature)
        };

        var token = tokenHandler.CreateToken(tokenDescriptor);
        return tokenHandler.WriteToken(token);
    }

    private async Task<Cashier> CreateTestCashierWithAuthAsync(Guid tenantId, string name, string email)
    {
        // Temporarily use admin token for creation
        var adminToken = GenerateTestJwtToken(tenantId, "admin");
        var originalAuth = _client.DefaultRequestHeaders.Authorization;

        try
        {
            _client.DefaultRequestHeaders.Authorization =
                new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", adminToken);

            var createRequest = new CreateCashierRequest(tenantId, name, email);
            var response = await _client.PostAsJsonAsync("/api/cashiers", createRequest);
            response.EnsureSuccessStatusCode();

            return await response.Content.ReadFromJsonAsync<Cashier>()
                   ?? throw new InvalidOperationException("Failed to create test cashier");
        }
        finally
        {
            _client.DefaultRequestHeaders.Authorization = originalAuth;
        }
    }
}

Performance and Load Testing ​

Response Time Testing ​

csharp
public class PerformanceIntegrationTests : IClassFixture<IntegrationTestFixture>
{
    private readonly IntegrationTestFixture _fixture;
    private readonly HttpClient _client;

    public PerformanceIntegrationTests(IntegrationTestFixture fixture)
    {
        _fixture = fixture;
        _client = fixture.HttpClient;
    }

    [Fact]
    public async Task CreateCashier_ResponseTime_WithinAcceptableLimit()
    {
        // Arrange
        var tenantId = Guid.NewGuid();
        var createRequest = new CreateCashierRequest(tenantId, "Performance Test", "perf@example.com");

        // Act
        var stopwatch = Stopwatch.StartNew();
        var response = await _client.PostAsJsonAsync("/api/cashiers", createRequest);
        stopwatch.Stop();

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.Created);
        stopwatch.ElapsedMilliseconds.Should().BeLessThan(1000); // Less than 1 second
    }

    [Fact]
    public async Task GetCashiers_LargeDataSet_PerformsWell()
    {
        // Arrange
        var tenantId = Guid.NewGuid();

        // Create large dataset
        var tasks = Enumerable.Range(1, 100)
            .Select(i => CreateTestCashierAsync(tenantId, $"User {i}", $"user{i}@example.com"));
        await Task.WhenAll(tasks);

        // Act
        var stopwatch = Stopwatch.StartNew();
        var response = await _client.GetAsync($"/api/cashiers?tenantId={tenantId}&pageSize=20");
        stopwatch.Stop();

        // Assert
        response.StatusCode.Should().Be(HttpStatusCode.OK);
        stopwatch.ElapsedMilliseconds.Should().BeLessThan(500); // Less than 500ms

        var pagedResult = await response.Content.ReadFromJsonAsync<PagedResult<Cashier>>();
        pagedResult!.Items.Should().HaveCount(20);
        pagedResult.TotalCount.Should().Be(100);
    }

    [Fact]
    public async Task ConcurrentRequests_HandledCorrectly()
    {
        // Arrange
        var tenantId = Guid.NewGuid();
        var concurrentRequests = 10;

        var tasks = Enumerable.Range(1, concurrentRequests)
            .Select(i => CreateTestCashierAsync(tenantId, $"Concurrent {i}", $"concurrent{i}@example.com"))
            .ToList();

        // Act
        var stopwatch = Stopwatch.StartNew();
        var results = await Task.WhenAll(tasks);
        stopwatch.Stop();

        // Assert
        results.Should().HaveCount(concurrentRequests);
        results.Should().OnlyContain(c => c != null);
        stopwatch.ElapsedMilliseconds.Should().BeLessThan(5000); // All requests complete in 5 seconds
    }

    private async Task<Cashier> CreateTestCashierAsync(Guid tenantId, string name, string email)
    {
        var createRequest = new CreateCashierRequest(tenantId, name, email);
        var response = await _client.PostAsJsonAsync("/api/cashiers", createRequest);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<Cashier>()
               ?? throw new InvalidOperationException("Failed to create test cashier");
    }
}

Test Data Management ​

Test Data Cleanup ​

csharp
public class TestDataManager
{
    private readonly AppDomainDb _db;
    private readonly List<Guid> _createdTenants = new();
    private readonly List<Guid> _createdCashiers = new();

    public TestDataManager(AppDomainDb db)
    {
        _db = db;
    }

    public async Task<Cashier> CreateTestCashierAsync(string name, string email, Guid? tenantId = null)
    {
        tenantId ??= Guid.NewGuid();
        _createdTenants.Add(tenantId.Value);

        var entity = new Data.Entities.Cashier
        {
            CashierId = Guid.CreateVersion7(),
            TenantId = tenantId.Value,
            Name = name,
            Email = email,
            CreatedDateUtc = DateTime.UtcNow,
            UpdatedDateUtc = DateTime.UtcNow
        };

        var created = await _db.Cashiers.InsertWithOutputAsync(entity);
        _createdCashiers.Add(created.CashierId);

        return created.ToModel();
    }

    public async Task CleanupAsync()
    {
        // Clean up in reverse order of dependencies

        if (_createdCashiers.Any())
        {
            await _db.Cashiers
                .Where(c => _createdCashiers.Contains(c.CashierId))
                .DeleteAsync();
        }

        // Could also clean up tenants if they were created
        // But typically tenants are shared across tests

        _createdCashiers.Clear();
        _createdTenants.Clear();
    }
}

// Usage in tests
public class IntegrationTestWithCleanup : IClassFixture<IntegrationTestFixture>, IAsyncLifetime
{
    private readonly IntegrationTestFixture _fixture;
    private TestDataManager _testDataManager = default!;

    public IntegrationTestWithCleanup(IntegrationTestFixture fixture)
    {
        _fixture = fixture;
    }

    public async Task InitializeAsync()
    {
        using var scope = _fixture.Services.CreateScope();
        var db = scope.ServiceProvider.GetRequiredService<AppDomainDb>();
        _testDataManager = new TestDataManager(db);
    }

    public async Task DisposeAsync()
    {
        await _testDataManager.CleanupAsync();
    }

    [Fact]
    public async Task TestWithAutoCleanup()
    {
        // Test creates data using _testDataManager
        // Data is automatically cleaned up after test
        var cashier = await _testDataManager.CreateTestCashierAsync("Test", "test@example.com");

        // Test logic here
        cashier.Should().NotBeNull();

        // No manual cleanup needed
    }
}

Running Integration Tests ​

Command Line ​

bash
# Run all integration tests
dotnet test tests/AppDomain.Tests/ --filter Category=Integration

# Run with specific verbosity
dotnet test tests/AppDomain.Tests/ --filter Category=Integration --verbosity detailed

# Run specific test class
dotnet test tests/AppDomain.Tests/ --filter "FullyQualifiedName~CashierApiTests"

# Run tests with coverage
dotnet test tests/AppDomain.Tests/ --filter Category=Integration --collect:"XPlat Code Coverage"

Environment Variables ​

Set environment variables for test configuration:

bash
# Set test environment
export ASPNETCORE_ENVIRONMENT=Testing

# Override connection strings if needed
export ConnectionStrings__DefaultConnection="Host=localhost;Database=test_db;..."

# Set log levels for debugging
export Logging__LogLevel__Default=Debug

CI/CD Pipeline Configuration ​

yaml
# GitHub Actions example
name: Integration Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  integration-tests:
    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3

    - name: Setup .NET
      uses: actions/setup-dotnet@v3
      with:
        dotnet-version: '10.0.x'

    - name: Restore dependencies
      run: dotnet restore

    - name: Build
      run: dotnet build --no-restore

    - name: Run Integration Tests
      run: |
        dotnet test tests/AppDomain.Tests/ \
          --filter Category=Integration \
          --no-build \
          --verbosity normal \
          --collect:"XPlat Code Coverage" \
          --logger trx \
          --results-directory TestResults/

    - name: Upload Test Results
      uses: actions/upload-artifact@v3
      if: always()
      with:
        name: test-results
        path: TestResults/

    - name: Upload Coverage Reports
      uses: codecov/codecov-action@v3
      with:
        files: TestResults/*/coverage.cobertura.xml

Best Practices ​

Test Organization ​

  1. Group related tests: Use test classes to group related functionality
  2. Use descriptive names: Test names should explain the scenario
  3. Follow AAA pattern: Arrange, Act, Assert
  4. Clean up test data: Ensure tests don't interfere with each other

Performance ​

  1. Use test containers: Provide isolated infrastructure
  2. Parallel execution: Configure tests to run in parallel where possible
  3. Optimize test data: Create minimal data needed for each test
  4. Cache expensive setup: Share expensive setup across tests

Reliability ​

  1. Handle timing issues: Use proper waits for async operations
  2. Isolate tests: Each test should be independent
  3. Use unique identifiers: Avoid conflicts with GUIDs/unique names
  4. Handle failures gracefully: Include proper error handling

Maintainability ​

  1. Use test utilities: Create reusable test helpers
  2. Keep tests simple: Complex test logic is hard to maintain
  3. Document complex scenarios: Add comments for non-obvious test logic
  4. Regular cleanup: Remove obsolete tests

Next Steps ​