Learnixo

Modular Monolith in .NET · Lesson 6 of 6

Testing a Modular Monolith

Testing Levels in a Modular Monolith

Unit tests (per module):
  → Test domain logic, value objects, policies in isolation
  → No database, no HTTP, no DI container
  → Fast: run in milliseconds

Module integration tests:
  → Test a module's use case end-to-end: command → handler → DB → result
  → Use a real database (Testcontainers) scoped to the module's DbContext
  → Fast: one SQL Server container, one schema

Cross-module integration tests:
  → Test that module A's event triggers the correct behavior in module B
  → Share the same container, both modules registered
  → Slower: validates the boundary works correctly

Architecture tests (ArchUnitNET):
  → Assert that cross-module coupling rules hold
  → Run as xUnit tests in CI — fail the build on violations

Module Integration Test Setup

C#
// Testcontainers: spin up SQL Server for the module under test
// NuGet: Testcontainers.MsSql, Microsoft.EntityFrameworkCore.SqlServer

public sealed class PrescriptionsModuleFixture : IAsyncLifetime
{
    private readonly MsSqlContainer _db = new MsSqlBuilder()
        .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
        .Build();

    public PrescriptionsDbContext DbContext { get; private set; } = null!;
    public IServiceProvider       Services  { get; private set; } = null!;

    public async Task InitializeAsync()
    {
        await _db.StartAsync();

        var services = new ServiceCollection();
        services.AddDbContext<PrescriptionsDbContext>(opts =>
            opts.UseSqlServer(_db.GetConnectionString()));

        // Register module handlers and services
        services.AddMediatR(cfg =>
            cfg.RegisterServicesFromAssembly(typeof(CreatePrescriptionHandler).Assembly));
        services.AddScoped<IPrescriptionRepository, PrescriptionRepository>();

        // Mock cross-module dependency — patients module not under test here
        services.AddScoped<IPatientQueryService>(_ =>
            Substitute.For<IPatientQueryService>());

        Services = services.BuildServiceProvider();
        DbContext = Services.GetRequiredService<PrescriptionsDbContext>();
        await DbContext.Database.MigrateAsync();
    }

    public async Task DisposeAsync()
    {
        await DbContext.DisposeAsync();
        await _db.DisposeAsync();
    }
}

Testing a Use Case Handler

C#
[Collection("PrescriptionsModule")]
public sealed class CreatePrescriptionHandlerTests
    : IClassFixture<PrescriptionsModuleFixture>
{
    private readonly PrescriptionsModuleFixture _fixture;

    public CreatePrescriptionHandlerTests(PrescriptionsModuleFixture fixture) =>
        _fixture = fixture;

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

        var patientService = _fixture.Services
            .GetRequiredService<IPatientQueryService>();
        patientService.GetByIdAsync(patientId, Arg.Any<CancellationToken>())
            .Returns(new PatientSummary(patientId, "MRN001", "Jane Doe", null));

        var mediator = _fixture.Services.GetRequiredService<ISender>();
        var command  = new CreatePrescriptionCommand(
            patientId, "Warfarin", 5.0m, "mg", "Once daily");

        // Act
        var result = await mediator.Send(command);

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

        var saved = await _fixture.DbContext.Prescriptions
            .FirstOrDefaultAsync(p => p.PatientId == patientId);
        saved.Should().NotBeNull();
        saved!.MedicationName.Should().Be("Warfarin");
    }

    [Fact]
    public async Task Handle_PatientNotFound_ReturnsFailure()
    {
        var patientService = _fixture.Services
            .GetRequiredService<IPatientQueryService>();
        patientService.GetByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
            .Returns((PatientSummary?)null);

        var mediator = _fixture.Services.GetRequiredService<ISender>();
        var command  = new CreatePrescriptionCommand(
            Guid.NewGuid(), "Warfarin", 5.0m, "mg", "Once daily");

        var result = await mediator.Send(command);

        result.IsFailure.Should().BeTrue();
        result.Error.Code.Should().Be("Patient.NotFound");
    }
}

Testing Cross-Module Event Flow

C#
// Test: PatientAdmitted event causes Prescriptions module to create a default schedule

public sealed class CrossModuleEventTests
    : IClassFixture<FullSystemFixture>  // both modules registered
{
    private readonly FullSystemFixture _fixture;

    public CrossModuleEventTests(FullSystemFixture fixture) => _fixture = fixture;

    [Fact]
    public async Task PatientAdmitted_CreatesDefaultPrescriptionSchedule()
    {
        // Arrange
        var patientId = Guid.NewGuid();
        var wardId    = Guid.NewGuid();
        var eventBus  = _fixture.Services.GetRequiredService<IModuleEventBus>();

        // Act — Patients module publishes the event
        await eventBus.PublishAsync(new PatientAdmittedModuleEvent(
            patientId, "MRN-002", "John Smith", wardId, DateTime.UtcNow));

        // Allow handlers to complete
        await Task.Delay(100);

        // Assert — Prescriptions module reacted
        var db = _fixture.Services.GetRequiredService<PrescriptionsDbContext>();
        var schedule = await db.PrescriptionSchedules
            .FirstOrDefaultAsync(s => s.PatientId == patientId);

        schedule.Should().NotBeNull();
        schedule!.WardId.Should().Be(wardId);
    }
}

Architecture Tests with ArchUnitNET

C#
// Enforce module boundaries in CI — violations fail the build
// NuGet: ArchUnitNET.xUnit

public sealed class ModuleBoundaryTests
{
    private static readonly Architecture Architecture =
        new ArchLoader()
            .LoadAssemblies(
                typeof(PatientsDbContext).Assembly,
                typeof(PrescriptionsDbContext).Assembly,
                typeof(LabResultsDbContext).Assembly)
            .Build();

    [Fact]
    public void PrescriptionsModule_MustNotReference_PatientsDomainAssembly()
    {
        // Prescriptions can use Patients.Api (IPatientQueryService)
        // but MUST NOT reference Patients.Domain (Patient entity, value objects)
        Classes()
            .That().ResideInNamespace("Prescriptions.*")
            .Should().NotDependOnAnyClassesThat()
            .ResideInNamespace("Patients.Domain.*")
            .Check(Architecture);
    }

    [Fact]
    public void NoModule_ShouldReference_AnotherModulesInfrastructure()
    {
        // Infrastructure is always internal to a module
        Classes()
            .That().DoNotResideInNamespace("*.Infrastructure.*")
            .Should().NotDependOnAnyClassesThat()
            .ResideInNamespace("*.Infrastructure.*")
            .Check(Architecture);
    }

    [Fact]
    public void DomainLayer_ShouldNotReference_ApplicationOrInfrastructure()
    {
        Classes()
            .That().ResideInNamespace("*.Domain.*")
            .Should().NotDependOnAnyClassesThat()
            .ResideInNamespace("*.Application.*")
            .Or().ResideInNamespace("*.Infrastructure.*")
            .Check(Architecture);
    }
}

Testing Module Registration

C#
// Verify the DI composition root is correct — catches missing registrations early

public sealed class ModuleRegistrationTests
{
    [Fact]
    public void PatientsModule_RegistersAllRequiredServices()
    {
        var services = new ServiceCollection();
        services.AddSingleton(Substitute.For<IConfiguration>());
        services.AddPatientsModule(BuildTestConfiguration());

        var provider = services.BuildServiceProvider();

        // Key services must resolve without throwing
        provider.GetRequiredService<IPatientRepository>().Should().NotBeNull();
        provider.GetRequiredService<IPatientQueryService>().Should().NotBeNull();
        provider.GetRequiredService<PatientsDbContext>().Should().NotBeNull();
    }

    private static IConfiguration BuildTestConfiguration() =>
        new ConfigurationBuilder()
            .AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["ConnectionStrings:Patients"] = "Server=localhost;Database=Test;Trusted_Connection=True;"
            })
            .Build();
}

Production issue I've seen: A modular monolith had no architecture tests. Developers under deadline pressure added direct EF Core queries to PatientsDbContext from inside the Prescriptions module — "just this once." Six months later, 14 cross-module DbContext usages existed. When the Patients team added row-level security to their schema, it broke prescriptions, lab results, and billing queries that nobody knew were reading from patients tables. Adding ArchUnitNET tests to CI the day after go-live would have caught the first violation before it merged.


Key Takeaway

Test each module in isolation using its own DbContext and a real database (Testcontainers). Mock cross-module dependencies (IPatientQueryService) in module-level tests. Test the event boundary separately with both modules registered against the same container. Run ArchUnitNET architecture tests in CI to fail the build on any cross-module coupling violation. The investment in architecture tests pays back within weeks on any team larger than two developers.