BrandGhost
Testing a Modular Monolith in C#: Unit and Integration Test Strategies

Testing a Modular Monolith in C#: Unit and Integration Test Strategies

Here's an opinion that might surprise you: testing modular monolith in C# is actually easier than testing microservices. Not just a little easier -- significantly easier. No test containers, no docker-compose files in your CI pipeline, no WireMock stubs for fake services. Just process-local code and two clearly-defined test tiers.

The challenge with modular monolith testing is not the mechanics -- it's understanding where the test boundaries actually belong. If you test it like a traditional monolith, you blur module boundaries and get slow, coupled tests. If you try to test it like microservices, you overengineer the isolation and lose the simplicity advantage. There's a better approach: lean into what the architecture already gives you.

This article shows you exactly how to do that using real tests from a .NET modular monolith demo app. You'll see actual unit tests for domain entities, the WebApplicationFactory<Program> setup for integration tests, and the full cross-module event flow test -- all in working C# code.

Why Modular Monoliths Are Test-Friendly

Before diving into code, it's worth understanding why the modular monolith architecture makes testing tractable in the first place. Three properties work in your favor.

Single process means no network. In a microservices setup, testing a workflow that crosses service boundaries requires network calls, test doubles for remote services, or running multiple containers. In a modular monolith, all modules run in the same process. Your integration tests use WebApplicationFactory<Program> to spin up the whole app in-memory -- no ports, no sockets, no networking required.

Module isolation means clear test boundaries. Each module owns its domain model, its repository, and its event handlers. Unit tests for the Tasks module don't need to know anything about the Notifications module. The boundaries are enforced by design, not by convention.

In-process events fire synchronously in tests. MediatR notifications dispatched from one module and handled in another are synchronous within the test process. The pattern used here follows the Observer Design Pattern in C# where publishers don't need to know their subscribers -- which means your integration tests can assert on notification side effects without polling, retries, or message bus infrastructure.

Together these properties mean you can cover the full behavior of your system -- domain rules, module contracts, and cross-module event flows -- with two focused test projects and zero external dependencies.

Test Strategy Overview

The strategy is two tiers:

  • Unit Tests -- test domain logic in complete isolation. No EF Core, no MediatR, no ASP.NET. Just C# objects.
  • Integration Tests -- test cross-module flows through the HTTP API using WebApplicationFactory<Program> with a real (but in-memory) database.

You do not need a third tier for "module tests" or "component tests." The combination of sharp unit tests and a thin integration layer covers everything worth testing in a modular monolith. More tiers add maintenance cost without adding signal.

Each project lives under a tests/ directory alongside the src/ directory:

modular-monolith-demo/
  src/
    Host/
    Modules/
      Projects/
      Tasks/
      Users/
      Notifications/
  tests/
    ModularMonolith.UnitTests/
      ProjectDomainTests.cs
      TaskDomainTests.cs
      UserDomainTests.cs
    ModularMonolith.IntegrationTests/
      TaskAssignmentIntegrationTests.cs

The unit test project references only the domain projects. The integration test project references the Host project, which pulls in the full app.

Tier 1: Unit Tests for Domain Logic

Unit tests in a modular monolith target the domain layer of each module. This means entity creation, validation rules, and state transitions. Nothing else belongs here.

What to test:

  • Factory methods on entities (Project.Create(...), TaskItem.Create(...), User.Create(...))
  • Invalid input rejection -- the entity should enforce invariants, not callers
  • Any domain behavior that involves state transitions

What to skip:

  • EF Core queries -- that's infrastructure, not domain
  • MediatR handlers -- test those in integration tests where the full pipeline runs
  • HTTP endpoints -- also integration territory

Here are the actual unit tests from the demo app. Notice how they rely on nothing except the domain project and xUnit -- no mocks, no test doubles:

using Tasks.Domain;
using Xunit;

namespace ModularMonolith.UnitTests;

public class TaskDomainTests
{
    [Fact]
    public void TaskItem_Create_WithValidData_CreatesTask()
    {
        var projectId = Guid.NewGuid();
        var title = "Test Task";
        var description = "Test Description";

        var task = TaskItem.Create(projectId, title, description);

        Assert.NotNull(task);
        Assert.Equal(title, task.Title);
        Assert.Equal(projectId, task.ProjectId);
    }

    [Fact]
    public void TaskItem_Create_WithEmptyTitle_ThrowsArgumentException()
    {
        var exception = Assert.Throws<ArgumentException>(() =>
        {
            TaskItem.Create(Guid.NewGuid(), "", "description");
        });

        Assert.Contains("Task title cannot be empty", exception.Message);
    }

    [Fact]
    public void TaskItem_Create_WithEmptyProjectId_ThrowsArgumentException()
    {
        var exception = Assert.Throws<ArgumentException>(() =>
        {
            TaskItem.Create(Guid.Empty, "title", "description");
        });

        Assert.Contains("Project ID is required", exception.Message);
    }
}

The pattern is consistent across every module. The ProjectDomainTests class follows the same shape:

using Projects.Domain;
using Xunit;

namespace ModularMonolith.UnitTests;

public class ProjectDomainTests
{
    [Fact]
    public void Project_Create_WithValidName_CreatesProject()
    {
        var name = "Test Project";
        var description = "Test Description";

        var project = Project.Create(name, description);

        Assert.NotNull(project);
        Assert.Equal(name, project.Name);
        Assert.Equal(description, project.Description);
    }

    [Fact]
    public void Project_Create_WithEmptyName_ThrowsArgumentException()
    {
        var exception = Assert.Throws<ArgumentException>(() =>
        {
            Project.Create("", "description");
        });

        Assert.Contains("Project name cannot be empty", exception.Message);
    }

    [Fact]
    public void Project_Create_WithNullName_ThrowsArgumentException()
    {
        var exception = Assert.Throws<ArgumentException>(() =>
        {
            Project.Create(null!, "description");
        });

        Assert.Contains("Project name cannot be empty", exception.Message);
    }
}

This is clean, obvious, and fast. Each test is microseconds. The whole unit test suite runs in under a second. These tests tell you immediately when someone breaks a domain invariant, and they do it without any test infrastructure overhead.

Tier 2: Integration Tests for Module Boundaries

Integration tests in a modular monolith serve a different purpose than unit tests. They test that modules cooperate correctly -- that a command in the Tasks module triggers a notification in the Notifications module, that the HTTP layer wires up correctly, and that the full request pipeline works end to end.

The key tool here is WebApplicationFactory<Program>. It spins up your full ASP.NET Core app in memory, using the real service registrations and real database (SQLite, in this demo). The Program class is exposed with public partial class Program { } so the test assembly can reference it.

The integration test project's .csproj references the Host project directly:

<ItemGroup>
  <ProjectReference Include="....srcHostHost.csproj" />
</ItemGroup>

And the test class uses IClassFixture<WebApplicationFactory<Program>> to share a single factory instance across tests in the class:

public class TaskAssignmentIntegrationTests 
    : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public TaskAssignmentIntegrationTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }
}

Now here's the flagship integration test -- the one that verifies the full cross-module event flow. When a task is assigned to a user, the Tasks module publishes a MediatR notification. The Notifications module handles that notification and stores a notification entry. The test proves this happened:

[Fact]
public async Task AssignTask_TriggersNotificationHandler_CrossModuleEventFlow()
{
    var client = _factory.CreateClient();

    // Step 1: Register a user (Users module)
    var userRequest = new { Name = "John Doe", Email = "[email protected]" };
    var userResponse = await client.PostAsJsonAsync("/users", userRequest);
    userResponse.EnsureSuccessStatusCode();
    var userResult = await userResponse.Content.ReadFromJsonAsync<UserResult>();
    Assert.NotNull(userResult);
    var userId = userResult.UserId;

    // Step 2: Create a project (Projects module)
    var projectRequest = new { Name = "Test Project", Description = "Test Description" };
    var projectResponse = await client.PostAsJsonAsync("/projects", projectRequest);
    projectResponse.EnsureSuccessStatusCode();
    var projectResult = await projectResponse.Content.ReadFromJsonAsync<ProjectResult>();
    Assert.NotNull(projectResult);
    var projectId = projectResult.ProjectId;

    // Step 3: Create a task (Tasks module)
    var taskRequest = new { ProjectId = projectId, Title = "Test Task", Description = "Test Description" };
    var taskResponse = await client.PostAsJsonAsync("/tasks", taskRequest);
    taskResponse.EnsureSuccessStatusCode();
    var taskResult = await taskResponse.Content.ReadFromJsonAsync<TaskResult>();
    Assert.NotNull(taskResult);
    var taskId = taskResult.TaskId;

    // Step 4: Assign the task -- triggers cross-module event
    var assignRequest = new { UserId = userId };
    var assignResponse = await client.PostAsJsonAsync($"/tasks/{taskId}/assign", assignRequest);
    assignResponse.EnsureSuccessStatusCode();

    await Task.Delay(100);

    // Step 5: Assert the Notifications module received and processed the event
    var notificationsResponse = await client.GetAsync("/notifications");
    notificationsResponse.EnsureSuccessStatusCode();
    var notifications = await notificationsResponse.Content.ReadFromJsonAsync<List<string>>();

    Assert.NotNull(notifications);
    Assert.Contains(notifications, n => n.Contains("Task assigned"));
    Assert.Contains(notifications, n => n.Contains("Test Task"));
}

This test crosses three modules (Users, Projects, Tasks) and validates a side effect in a fourth (Notifications). In a microservices architecture, this same scenario would require running four services, a message broker, and a network between all of them. Here it's a single in-memory HTTP call sequence with a 100ms wait to let the notification handler complete.

The Observer vs Mediator Pattern in C# article explains the design trade-offs behind this event model. MediatR's notification dispatching is what makes the cross-module event flow synchronous and testable in-process.

Testing Module Isolation

One thing modular monolith testing should verify -- but often doesn't -- is that modules don't accidentally reach into each other's internals. The whole point of module isolation is that the Projects module cannot directly query the Tasks database, and the Notifications module cannot directly call the Users repository.

In this demo app, isolation is enforced by keeping each module's DbContext and repositories internal to the module's assembly. The module registration method (builder.Services.AddTasksModule(...)) is the only public surface. To verify this kind of isolation in your tests:

  • Each unit test only imports from the module under test. TaskDomainTests imports Tasks.Domain -- not Projects.Domain, not Users.Domain.
  • Integration tests that span modules go through the HTTP API (the module's public interface), not through directly resolving internal services from the DI container.

If you find yourself resolving a module's internal repository in an integration test for a different module, that's a signal your module boundaries have leaked.

Testing Event Contracts

Cross-module events are the most brittle surface in a modular monolith. An event published by the Tasks module and handled by the Notifications module is an implicit contract. If someone renames a property on the event, the handler silently breaks.

The integration test above covers this implicitly -- if the TaskAssignedEvent changes shape, the notification handler won't process it and the final assertion fails. But you can make this more explicit:

  • Keep cross-module events in a shared contracts project or in the publishing module's public API surface
  • Use record types for events so structural changes cause compile errors rather than silent failures
  • Have at least one integration test per published event that asserts the expected handler output

The AssignTask_TriggersNotificationHandler_CrossModuleEventFlow test is exactly this kind of contract test. It fails if the event stops being published, if the handler stops processing it, or if the notification format changes in a breaking way.

The Observer Design Pattern in C# and the Decorator Design Pattern in C# both offer complementary patterns for building extensible event pipelines that stay testable.

Test Project Organization

The unit test project has one file per domain class under test:

ModularMonolith.UnitTests/
  ProjectDomainTests.cs     // Tests Project.Create and Project domain rules
  TaskDomainTests.cs        // Tests TaskItem.Create and TaskItem domain rules
  UserDomainTests.cs        // Tests User.Create and User domain rules

The integration test project has one file per integration scenario:

ModularMonolith.IntegrationTests/
  TaskAssignmentIntegrationTests.cs   // Full request/event/handler flows

As your app grows, you might add files like ProjectLifecycleIntegrationTests.cs or UserRegistrationIntegrationTests.cs. The organizing principle is: one file per related workflow, not one file per module.

Keep the unit test project's project references lean. It should only reference the domain projects -- not the infrastructure or application layers. This enforces the boundary by making it a compile error to call into EF Core or MediatR from unit tests.

What NOT to Test

A common mistake when testing modular monoliths is over-testing. Here's what to skip:

Don't test EF Core itself. Entity Framework has its own test suite. You don't need to verify that dbContext.Projects.Add(project) saves a project -- that's framework behavior, not your business logic.

Don't mock everything. In unit tests, mocks are unnecessary because domain entities are pure C# objects. In integration tests, you want the real app behavior -- mock as little as possible. If you're mocking a module's repository in an integration test, you've probably identified a unit test in disguise.

Don't test implementation details. The test AssignTask_TriggersNotificationHandler_CrossModuleEventFlow asserts that a notification was created -- it doesn't assert that IMediator.Publish was called with a specific event object. Testing the internal MediatR call couples your test to the implementation and hides the actual behavior you care about.

Don't duplicate coverage. If a business rule is thoroughly covered by unit tests, the integration test for that module doesn't need to re-verify the same rule. Integration tests are for verifying that modules cooperate -- not for re-testing domain logic.

Comparison with Testing Microservices

To make this concrete, consider what the same cross-module test would require in a microservices version of this app:

Concern Modular Monolith Microservices
Services to run 1 (in-process) 4 (Users, Projects, Tasks, Notifications)
Message broker None RabbitMQ, Kafka, or Azure Service Bus
Test setup WebApplicationFactory<Program> docker-compose or Testcontainers
Network None (in-process HTTP) Real or mocked HTTP between services
Event timing 100ms Task.Delay Polling loop or event bus assertions
CI requirements dotnet test Docker daemon required

The modular monolith wins on every dimension for local development and CI speed. The test that verifies the cross-module notification flow runs in under 200ms with no external dependencies. The equivalent microservices test would take several seconds just to start up the containers.

This is the practical payoff of keeping your domain logic in a well-structured monolith. The Strategy Design Pattern in C# and Builder Design Pattern in C# are two additional patterns worth knowing when building the domain logic layer that your unit tests will cover.

If your app needs background processing between modules, you can add hosted services -- see Hosted Services with Needlr for how background workers fit into the overall lifecycle -- and still test them in-process with WebApplicationFactory.

FAQ

What xUnit features do you use for modular monolith tests?

The demo app uses [Fact] for single-case tests and IClassFixture<T> to share a WebApplicationFactory<Program> instance across all tests in an integration test class. For parameterized unit tests (multiple inputs for the same rule), [Theory] with [InlineData] is the right choice.

Should each module have its own test project?

Not necessarily. The demo app uses two shared projects -- one for all unit tests and one for all integration tests. Per-module test projects make sense when modules are large enough that their test suites need independent CI stages or different team ownership. For most codebases, shared projects with well-named files are simpler and easier to maintain.

How do you handle test database isolation between integration tests?

WebApplicationFactory<Program> spins up the app once per test class (via IClassFixture). The demo uses SQLite, which writes to files or runs in-memory. For full isolation between test runs, you can override the connection string in the factory to use a per-test SQLite in-memory database, or simply accept that a shared database between tests in the same class requires tests to be written without conflicting data assumptions.

Can you unit test MediatR handlers without a full app?

Yes. A handler that implements IRequestHandler<TRequest, TResponse> is just a class -- you can construct it directly, inject your dependencies (either real or simple fakes), and call Handle on it. You don't need MediatR infrastructure to invoke the handler logic. This is useful for testing complex handler behavior in isolation before covering the full pipeline in integration tests.

How do you test that modules are truly isolated from each other?

The strongest test is a compile-time check: if the Tasks module's internal repository type is not exported (internal access modifier), then no other module can reference it. For runtime isolation, write integration tests that only interact with a module through its public API (the ITasksModule interface or the HTTP endpoints). If your test needs to resolve TasksDbContext from the DI container inside a test for the Notifications module, that's a boundary violation worth fixing.

Is WebApplicationFactory slow for large modular monoliths?

WebApplicationFactory<Program> startup time scales with the number of services registered, not the number of HTTP endpoints. For a modular monolith with 10-15 modules, startup typically takes 1-3 seconds. You amortize this cost by using IClassFixture so the factory starts once per test class and not once per test. For the most expensive cases, ICollectionFixture lets you share a factory across multiple test classes.

When should you add a third test tier?

Rarely. The only scenario where a third tier makes sense is when you have a subset of tests that require an actual external dependency -- a real database engine like Postgres that behaves differently from SQLite, for example. In that case, a separate "contract test" project that uses Testcontainers for that specific dependency is reasonable. But most modular monolith testing scenarios are fully covered by the two-tier approach described here.

Conclusion

Testing a modular monolith in C# is straightforward once you understand the two-tier model: unit tests for domain entity logic and integration tests for cross-module event flows. The architecture does most of the work -- module isolation gives you clear test boundaries, and single-process deployment eliminates the network infrastructure that makes microservices integration tests painful.

The test code from this demo app -- TaskDomainTests, ProjectDomainTests, UserDomainTests, and TaskAssignmentIntegrationTests -- is the pattern to follow. Keep unit tests laser-focused on domain behavior. Keep integration tests focused on module cooperation through the public API. Use WebApplicationFactory<Program> for everything in the integration tier. And resist the urge to mock your way into testing implementation details.

The result is a fast, reliable, maintainable test suite that gives you confidence in your module boundaries and your business logic -- without the operational overhead of microservices testing infrastructure.

Module Communication Patterns in Modular Monolith C#: In-Process Events and Contracts

Learn module communication modular monolith C# patterns: in-process events, MediatR notifications, and contracts to keep your modules cleanly decoupled.

How to Build a Modular Monolith in C# from Scratch: Step-by-Step Guide

Learn how to build a modular monolith in C# from scratch with this step-by-step guide. Create bounded context modules, enforce isolation, and wire them together in .NET 9.

Modular Monolith in C#: Complete Implementation Guide for .NET Developers

Build a modular monolith in C# with bounded contexts, module communication, and data isolation. Complete implementation guide with real .NET code examples.

An error has occurred. This application may no longer respond until reloaded. Reload