Testing Modular Monoliths: System Integration Testing
Modular monoliths strike a balance between the simplicity of monolithic architecture and the flexibility of microservices. By breaking down applications into cohesive modules, modular monoliths enable easier development and maintenance. However, they still have a single codebase and deployment unit.
A critical aspect of the success of a modular monolith is the interaction between its modules.
System integration testing (SIT) is an approach to verifying the collaboration of these modules. It involves testing the integration points and communication mechanisms between modules.
System integration testing allows us to validate the entire system's behavior. This allows us to catch problems early before they become big headaches.
In this article, we'll learn how to test a modular monolith using system integration testing.
We'll look at real-world examples and discuss why this testing approach is useful.
What Is System Integration Testing?
System integration testing (SIT) is an approach to testing the interactions between various modules within a single system. The system could contain many external services, which should also be included during testing.
System integration testing is the perfect testing approach for modular monoliths. It allows you to mimic the real-world execution of your application (as you'll see later).
The key benefits of system integration testing are:
Detecting integration issues: Discover problems from module integrations that unit testing might miss.
Validating business logic: Confirm that end-to-end business processes function correctly across modules.
Ensuring data integrity: Verify that data flows accurately and consistently between modules.
Improving system stability: Identify and resolve issues early, leading to a more reliable system.
Building confidence: Ensure that the system is well-integrated and ready for deployment.
Modular Monolith System Example
A modular monolith consists of multiple modules, each with a distinct responsibility. Modules represent high-level components with well-defined boundaries. The modules essentially group together related functionalities (use cases). If you want to learn more about this software architecture, check out this introduction to modular monoliths.
Let's consider a hypothetical ticketing system that consists of several modules:
Users Module: Handles user administration and authentication.
Events Module: Manages events, scheduling, and ticket availability.
Ticketing Module: Allows users to purchase tickets for various events.
Attendance Module: Allows users to check into events using their tickets.
Source: Modular Monolith Architecture
During system integration testing, we want to ensure that all these modules interact correctly and fulfill the business requirements of the ticketing system.
Testing Modular Monoliths: User Registration
The modules in our ticketing system represent different bounded contexts. The Users Module uses the term User
as its core entity. However, the Ticketing Module uses the term Customer
because it's more aligned with its core responsibility of selling tickets. Conceptually, both entities represent the same person within our system.
Here's the scenario we want to test:
A user registers with our application through the Users Module
The Users Module publishes an integration event to notify other modules
The Ticketing Module handles the integration event and creates a customer record
If you haven't figured it out by now, our modules communicate asynchronously using messaging.
Why is this important for our tests?
There is a time delay from when something occurs in one module until the other modules are notified and handle the integration events. Our system integration tests will have to take into account this delay.
Here's the test for this scenario:
public class RegisterUserTests : BaseIntegrationTest
{
public RegisterUserTests(IntegrationTestWebAppFactory factory)
: base(factory)
{
}
[Fact]
public async Task RegisterUser_Should_PropagateToTicketingModule()
{
// [Users Module] - Register user
var command = new RegisterUserCommand(
Faker.Internet.Email(),
Faker.Internet.Password(6),
Faker.Name.FirstName(),
Faker.Name.LastName());
Result<Guid> userResult = await Sender.Send(command);
userResult.IsSuccess.Should().BeTrue();
// [Ticketing Module] - Get customer
Result<CustomerResponse> customerResult = await Poller.WaitAsync(
TimeSpan.FromSeconds(15),
async () =>
{
var query = new GetCustomerQuery(userResult.Value);
var customerResult = await Sender.Send(query);
return customerResult;
});
// Assert
customerResult.IsSuccess.Should().BeTrue();
customerResult.Value.Should().NotBeNull();
}
}
A lot is going on here, so let's unpack the steps:
We're using the
WebApplicationFactory
to run an in-memory application instanceAny external dependencies run in a Docker container using Testcontainers
We send the
RegisterUserCommand
to register a user in the Users ModuleThen, we have to poll the Ticketing Module by sending a
GetCustomerQuery
The
Poller
allows us to wait until the system eventually becomes consistent
You can learn more about integration testing with Testcontainers in this article.
System integration tests take longer to execute than integration tests for one module. This is a side effect of testing a bigger slice of the overall system in each test case.
The Poller
class allows you to execute a delegate until you receive a successful result or a timeout occurs. You can customize the timeout duration based on the scenario you are testing.
internal static class Poller
{
private static readonly Error Timeout =
Error.Failure("Poller.Timeout", "The poller has time out");
internal static async Task<Result<T>> WaitAsync<T>(
TimeSpan timeout,
Func<Task<Result<T>>> func)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
DateTime endTimeUtc = DateTime.UtcNow.Add(timeout);
while (DateTime.UtcNow < endTimeUtc &&
await timer.WaitForNextTickAsync())
{
Result<T> result = await func();
if (result.IsSuccess)
{
return result;
}
}
return Result.Failure<T>(Timeout);
}
}
Testing Modular Monoliths: Adding Ticket to Cart
Here's a more complex scenario testing adding a ticket to the customer's cart:
A user registers with our application through the Users Module
The Users Module publishes an integration event to notify other modules
The Ticketing Module handles the integration event and creates a customer record
The Ticketing Module creates a dummy event and adds a ticket to the customer's cart
This scenario mimics a user registering with our system, finding a ticket they want to purchase, and adding it to their cart. We can extend this scenario with more test cases, like ticket purchases.
public sealed class AddItemToCartTests : BaseIntegrationTest
{
private const decimal Quantity = 10;
public AddItemToCartTests(IntegrationTestWebAppFactory factory)
: base(factory)
{
}
[Fact]
public async Task Customer_ShouldBeAbleTo_AddItemToCart()
{
// [Users Module] - Register user
var command = new RegisterUserCommand(
Faker.Internet.Email(),
Faker.Internet.Password(6),
Faker.Name.FirstName(),
Faker.Name.LastName());
Result<Guid> userResult = await Sender.Send(command);
userResult.IsSuccess.Should().BeTrue();
// [Ticketing Module] - Get customer
Result<CustomerResponse> customerResult = await Poller.WaitAsync(
TimeSpan.FromSeconds(15),
async () =>
{
var query = new GetCustomerQuery(userResult.Value);
var customerResult = await Sender.Send(query);
return customerResult;
});
customerResult.IsSuccess.Should().BeTrue();
// [Ticketing Module] - Add item to cart
CustomerResponse customer = customerResult.Value;
var ticketTypeId = Guid.NewGuid();
await Sender.CreateEventAsync(Guid.NewGuid(), ticketTypeId, Quantity);
Result result = await Sender.Send(
new AddItemToCartCommand(customer.Id, ticketTypeId, Quantity));
// Assert
result.IsSuccess.Should().BeTrue();
}
}
What I like about system integration testing is that the test cases aren't complicated to write. The tests execute the use cases of our system and verify the side effects. Building your system around use cases has a few advantages, the main one being that you can focus on the core business logic. But it also makes (integration) testing easier. The use case runs by sending a command or a query. We can execute the use cases in some logical order and check that we get the expected result.
In Conclusion
System integration testing is a crucial step in creating a successful modular monolith. By testing how different modules work together, you can find and fix problems before they become bigger issues. This makes your software more reliable and saves you time and resources in the long run.
If you want to dive deeper into building modular systems, check out Modular Monolith Architecture. There's an entire chapter dedicated to testing, including advanced techniques like system integration testing, using Testcontainers for external services, and automated testing with CI/CD pipelines.
That's all for today.
See you next week.
P.S. Whenever you’re ready, there are 3 ways I can help you:
Pragmatic Clean Architecture: Join 2,900+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
Modular Monolith Architecture: Join 800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
Patreon Community: Join a community of 1,050+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.
Subscribe to my newsletter
Read articles from Milan Jovanović directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Milan Jovanović
Milan Jovanović
I'm a seasoned software architect and Microsoft MVP for Developer Technologies. I talk about all things .NET and post new YouTube videos every week.