Skip to main content
SEMastery
Testingintermediate

Testing Modular Monoliths: System Integration Testing in .NET 10

A friendly .NET 10 guide to system integration testing for modular monoliths: test module boundaries, cross-module flows, real databases with Testcontainers.

11 min readUpdated September 24, 2025

The railway station analogy

Think of a big railway station like the ones in Mumbai or Chennai. It has a ticket counter, a luggage room, a platform announcement system, and a train scheduling office. Each part has its own staff and its own job. The ticket clerk can sell tickets all day. The announcer can read names all day. Each one works fine on their own.

But a passenger journey is not one counter. A passenger buys a ticket, the system books a seat, the luggage room tags the bags, and the announcer calls the right platform at the right time. The real question is not "can the ticket clerk sell a ticket?" It is "when a passenger buys a ticket, does the seat get booked, the luggage tagged, and the platform announced, all in the correct order?"

A modular monolith is that station. Each counter is a module. The counters are separate and tidy, but they still serve one journey together. System integration testing is sending one pretend passenger through the whole station and checking that every counter did its part.

This guide shows you how to write those tests in .NET 10, in plain steps a beginner can follow.

What a modular monolith looks like

A modular monolith is one app you deploy as a single unit. Inside, it is split into modules. Each module keeps its code together, owns its own data, and hides its insides. Other modules can only talk to it through a small public door.

One deployable app, split into separate modules that each own their data.

The key rule: a module never reaches into another module's tables or internal classes. It asks through a public interface or reacts to an event. This is what keeps the monolith clean and what makes it possible to split into microservices later if you ever need to.

Because everything runs in one process, testing is actually easier than with microservices. There is no network to mock and no flaky service to wait for. You can start the whole app and watch modules cooperate in real time.

Where system integration tests fit

You still want the test pyramid. Lots of small, fast unit tests at the bottom. Some module-level integration tests in the middle. A smaller number of system integration tests at the top that cross module boundaries.

The test pyramid for a modular monolith, with cross-module tests at the top.

Here is the difference in one table.

Test typeWhat it checksSpeedUses a real DB?
Unit testOne class or method on its ownVery fastNo
Module integration testOne module end to endMediumYes (Testcontainers)
System integration testTwo or more modules working togetherSlowerYes (Testcontainers)

System integration tests are the focus here. They are the ones that catch the bug where Sales thinks it told Inventory to reduce stock, but Inventory never heard the message.

The tools you need

Two tools do most of the heavy lifting in .NET 10.

WebApplicationFactory<T> starts your whole app in memory inside the test process. It gives you an HttpClient that talks straight to your app, no network port needed. You can also swap services before the app starts, which is how you point the database at a test container.

Testcontainers spins up real dependencies (PostgreSQL, Redis, RabbitMQ) inside Docker just for your tests. When the tests finish, the containers are thrown away. You get a clean, real database every run.

How a system integration test runs

Start container
Boot app
Send request
Check side effect
Tear down

Steps

1

Start container

Testcontainers starts a real PostgreSQL

2

Boot app

WebApplicationFactory starts the app, pointed at the container

3

Send request

HttpClient calls one module's endpoint

4

Check side effect

Poll another module to see it reacted

5

Tear down

Stop container, get a clean slate

The path from test start to clean teardown.

A quick note on libraries. Some popular .NET messaging and mediator libraries, namely MediatR and MassTransit, moved to commercial licensing in their newer versions. That does not change how you test. If your modules talk through one of these, or through a hand-rolled event bus, your system integration tests still work the same way: trigger one module, then verify the other module changed.

Setting up the test factory

Start by building a factory that boots your app and points it at a Testcontainers database. Implement IAsyncLifetime so the container starts and stops cleanly around the whole test run.

using DotNet.Testcontainers.Builders;
using Microsoft.AspNetCore.Mvc.Testing;
using Testcontainers.PostgreSql;
 
public sealed class ShopAppFactory
    : WebApplicationFactory<Program>, IAsyncLifetime
{
    private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
        .WithImage("postgres:17")
        .Build();
 
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        // Pass the container connection string in as a setting.
        // Do not hardcode it.
        builder.UseSetting(
            "ConnectionStrings:Shop",
            _db.GetConnectionString());
    }
 
    public async Task InitializeAsync()
    {
        await _db.StartAsync();
    }
 
    public new async Task DisposeAsync()
    {
        await _db.StopAsync();
    }
}

Notice we use UseSetting to inject the connection string instead of hardcoding it. This is the clean way: the app reads its config exactly like it would in production, and the test just feeds it a different value.

Writing your first cross-module test

Now the fun part. We will test a real flow that crosses two modules:

  1. A customer places an order in the Sales module.
  2. The Inventory module should reduce the stock for that product.

In a modular monolith, Sales does not write to Inventory's tables. It publishes an event, and Inventory handles it. That handling might happen a moment later, so we poll until the system becomes consistent instead of checking instantly.

public class PlaceOrderTests : IClassFixture<ShopAppFactory>
{
    private readonly HttpClient _client;
    private readonly ShopAppFactory _factory;
 
    public PlaceOrderTests(ShopAppFactory factory)
    {
        _factory = factory;
        _client = factory.CreateClient();
    }
 
    [Fact]
    public async Task Placing_an_order_reduces_inventory_stock()
    {
        // Arrange: product P-100 starts with 10 units in stock.
        var productId = "P-100";
 
        // Act: Sales module receives a new order for 3 units.
        var order = new { ProductId = productId, Quantity = 3 };
        var response = await _client.PostAsJsonAsync("/sales/orders", order);
        response.EnsureSuccessStatusCode();
 
        // Assert: the Inventory module should eventually show 7 left.
        var stock = await PollUntilStockIs(productId, expected: 7);
        Assert.Equal(7, stock);
    }
}

The order endpoint belongs to Sales. The stock check hits Inventory. Neither test code nor either module pokes at the other's database. That is exactly what a healthy modular monolith looks like, and the test proves the wiring between them works.

Polling for eventual consistency

When modules talk through events, the second module reacts a little after the first. So we keep asking "are you done yet?" for a short while before giving up.

private async Task<int> PollUntilStockIs(string productId, int expected)
{
    var deadline = DateTime.UtcNow.AddSeconds(5);
 
    while (DateTime.UtcNow < deadline)
    {
        var stock = await _client.GetFromJsonAsync<StockDto>(
            $"/inventory/stock/{productId}");
 
        if (stock is not null && stock.Quantity == expected)
        {
            return stock.Quantity;
        }
 
        await Task.Delay(100);
    }
 
    throw new TimeoutException(
        $"Stock for {productId} never reached {expected}.");
}
 
public record StockDto(string ProductId, int Quantity);

Note the route uses curly braces, so in prose I would write it as GET /inventory/stock/{productId} inside backticks. Inside the fenced code block above, the braces are fine. This polling pattern is the heart of testing event-driven module communication. It waits patiently, then fails loudly with a clear message if the side effect never happens.

The cross-module flow: an order in Sales triggers a stock change in Inventory.

Keeping tests from stepping on each other

Integration tests share one database container, so they can trip over each other if you are not careful. Give each test a clean, known starting point.

There are two common ways to stay clean:

  • Reset the data between tests (for example with the Respawn library, which deletes rows from your tables).
  • Use unique data per test, so two tests never read the same record.
ProblemWhat goes wrongSimple fix
Shared mutable stateOne test changes data another test readsReset DB or use unique IDs
Test order dependenceTests pass only in a certain orderMake each test self-contained
Parallel DB writesTwo tests write at once and clashPut DB tests in one xUnit collection
Leftover containersOld containers slow the machineStop them in DisposeAsync

Run your database-touching tests in a single xUnit collection so they do not run in parallel and fight over the same rows. It is a small change that removes a whole class of flaky failures.

A clean test seam for fakes

Sometimes a module talks to the outside world, like sending an email or charging a card. You do not want a real email going out during a test. Swap the real service for a fake one in the factory.

Swapping a real service for a fake in tests

App boots
Find real service
Remove it
Add fake
Run test

Steps

1

App boots

WebApplicationFactory starts the app

2

Find real service

Locate the real IEmailSender registration

3

Remove it

Take the real one out of the container

4

Add fake

Register a fake that records calls

5

Run test

Assert the fake was asked to send

The factory replaces one registration so the rest of the app stays real.
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
    builder.UseSetting("ConnectionStrings:Shop", _db.GetConnectionString());
 
    builder.ConfigureTestServices(services =>
    {
        // Remove the real email sender.
        services.RemoveAll<IEmailSender>();
 
        // Add a fake that just records what it was asked to send.
        services.AddSingleton<IEmailSender, FakeEmailSender>();
    });
}

The trick is to swap as little as possible. Keep the database real. Keep the modules real. Only fake the things you truly cannot run in a test, like a payment provider or an SMS gateway. The more of the real system you keep, the more your test actually proves.

A simple state view of a test run

It helps to picture the test as a tiny machine with a few states. It moves from setup, to action, to checking, and finally to cleanup.

The life of a single system integration test.

Tips that keep these tests healthy

A few habits make a big difference over the life of a project.

Keep one factory per app, shared across a test class with IClassFixture. Starting a container is slow, so you do not want to do it for every single test. Share it.

Test behaviour, not internals. A good system integration test reads like a user story: "place an order, expect stock to drop." It should not care which class did the work inside Inventory. That way, you can refactor a module's insides freely and the test still passes.

Give failures a clear message. When a poll times out, say what you expected and what you got. Future you will thank present you at 11pm when a test goes red.

Run them in CI on every pull request. Testcontainers works in GitHub Actions and most CI systems because they have Docker available. Catching a broken module boundary before merge is far cheaper than catching it in production.

Quick recap

  • A modular monolith is one deployable app split into separate modules. Each module owns its data and hides its insides behind a public door.
  • System integration testing checks that modules work together, not just that each one works alone. This is where modular monoliths most often break.
  • Use WebApplicationFactory<T> to boot the whole app in memory, and Testcontainers to run a real database in Docker just for the test.
  • Inject the container connection string with UseSetting instead of hardcoding it.
  • For event-driven flows, poll until the second module catches up, then assert. Fail loudly with a clear message on timeout.
  • Keep tests clean: reset data or use unique IDs, run DB tests in one collection, and stop containers in DisposeAsync.
  • Only fake what you truly cannot run, like email or payments. Keep everything else real so the test proves more.
  • Run these tests in CI so a broken module boundary is caught before it reaches production.

References and further reading

Related Posts