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 violationsModule Integration Test Setup
// 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
[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
// 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
// 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
// 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
PatientsDbContextfrom 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.