Learnixo
Back to blog
AI Systemsintermediate

Testing a Modular Monolith — Module-Level and Integration Tests

Test a modular monolith effectively: in-process module tests, cross-module integration tests with real databases, testing module boundaries, and validating architecture constraints with ArchUnitNET.

Asma Hafeez KhanMay 16, 20265 min read
Modular MonolithTestingIntegration TestsArchUnitNET.NETxUnit
Share:𝕏

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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.