Simplify Your Integration Testing with Test Containers

Clément Sannier
Clément Sannier
Simplify Your Integration Testing with Test Containers
Table of Contents
Table of Contents

Learn how to implement Test Containers in .NET for robust integration testing. Discover best practices, practical examples, and advanced implementation techniques using Docker.

In the .NET ecosystem, reliable integration testing remains a constant challenge. Developers typically juggle between different approaches: in-memory databases, mocks, or dedicated test environments. Test Containers emerge as an innovative solution, offering an optimal balance between reliability and practicality.

Integration testing is crucial in modern software development, going beyond unit tests to verify how components work together in real-world scenarios. While unit tests verify individual pieces in isolation, integration tests ensure your application communicates correctly with databases, APIs, and external services, catching issues that would only appear in production.

Test Containers simplifies this process by providing isolated, reproducible environments that closely match production setups, making it easier to implement and maintain reliable integration tests.

What is TestContainers ?

A Test Container is a modern approach enabling lightweight, isolated instances of external dependencies directly in your test pipeline. This technology relies on three essential pillars:

  • Complete isolation: each container is totally independent
  • Production fidelity: using the same Docker images as production
  • Automation: automatic resource creation and cleanup

The architecture relies on Docker, allowing dynamic creation and destruction of containers during test execution. This approach differs from traditional solutions like LocalDB or in-memory databases through its flexibility and complete isolation, eliminating false positives related to implementation differences.

Here are some of the most commonly used Docker images available with Test Containers. This list represents just a small sample of what you can use, as Test Containers supports any Docker-compatible : Microsoft SQL Server, PostgreSQL, MySQL, MongoDB, Redis, RabbitMQ, Apache Kafka, Nginx, and more :

Modules - Testcontainers for .NET

Practical Case : Handling Order in goat market

In a typical e-commerce application, handling orders correctly is crucial - we need to ensure that orders are properly saved to and retrieved from the database. The test validates this core functionality by creating an order (like when a customer completes a purchase) and then verifying we can retrieve it correctly from the database. Instead of mocking the database, we're using Test Containers to spin up a real SQL Server instance, which gives us confidence that our order processing will work correctly in the production environment.

Start with TestContainers

To begin with Test Containers, several prerequisites are necessary:

  • Docker Desktop (version 2.0 or higher)
  • .NET SDK 6.0 or higher
  • A unit testing project like xUnit (use in this case), NUnit, or MSTest
Get started with Docker for remote development with containers
A complete guide to get started with Docker Desktop on Windows or WSL. Including support offered by Microsoft and variety of Azure services.

Installation is done via NuGet with the following packages:

dotnet add package Testcontainers
dotnet add package Testcontainers.MsSql
dotnet add package Microsoft.NET.Test.Sdk

Let's break down this piece of code that configures a SQL Server container:

new MsSqlBuilder()
	.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
	.WithPassword(SA_PASSWORD)
	.WithPortBinding(1433, true)
    .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433))
    .Build();

The code initializes a SQL Server container with specific settings using a builder pattern :

  • MsSqlBuilder() creates a new configuration for a SQL Server container.
  • .WithImage() specifies the Docker image to use - in this case, it's using the official Microsoft SQL Server 2022 image.
  • .WithPassword() sets the SA (System Administrator) password for the database.
  • .WithPortBinding(1433, true) maps the container's SQL Server port (1433) to a port on your host machine - the true parameter allows automatic port assignment if 1433 is already in use.
  • .WithWaitStrategy() ensures the test doesn't start until SQL Server is actually ready to accept connections - it keeps checking port 1433 until the database is fully started and accessible.

Finally, .Build() creates the container with all these configurations.

This is the complete code for robust configuration with SQL Server:

public sealed class SqlServerContainer : IAsyncLifetime
{
    private readonly MsSqlContainer _container;
    private const string SA_PASSWORD = "Password@123";
    
    private OrderDbContext _dbContext = default!;

    public SqlServerContainer()
    {
        // Configure and initialize the SQL Server container
        _container = new MsSqlBuilder()
            .WithImage("mcr.microsoft.com/mssql/server:2022-latest") // Use latest SQL Server image
            .WithPassword(SA_PASSWORD) // Set SA password
            .WithPortBinding(1433, true) // Bind container port
            .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433)) // Wait until the port is ready
            .Build();
    }

    // Retrieve the connection string for the running container
    public string ConnectionString => _container.GetConnectionString();

    public async Task InitializeAsync()
    {
        await _container.StartAsync(); // Start the container
        await InitializeDatabaseAsync(); // Ensure the database is initialized
    }

    public async Task DisposeAsync() => await _container.DisposeAsync(); // Cleanup resources

    private async Task InitializeDatabaseAsync()
    {
        await using var connection = new SqlConnection(ConnectionString);
        await connection.OpenAsync(); // Open database connection
        
        // Configure DbContext options to use the SQL Server connection
        var options = new DbContextOptionsBuilder<OrderDbContext>()
            .UseSqlServer(connection)
            .Options;
        
        _dbContext = new OrderDbContext(options);
        
        await _dbContext.Database.EnsureCreatedAsync(); // Ensure database schema is created before use
    }
}

xUnit's IAsyncLifetime interface is designed for managing asynchronous setup and cleanup in your tests. It provides two key methods: InitializeAsync(), which runs before each test, and DisposeAsync(), which runs after each test. Test Containers leverages this xUnit interface to manage container lifecycles - starting containers before tests run and cleaning them up afterward. This integration with xUnit's testing lifecycle makes it seamless to work with Docker containers in your integration tests, ensuring proper resource management and test isolation.

Create integration test

Let's implement a complete integration test for an order service:

[Trait("Category", "Integration")] // Marks the test category as 'Integration'
[Trait("Container", "SqlServer")] // Specifies that this test uses a SQL Server container
public sealed class OrderRepositoryIntegrationTests : IClassFixture<SqlServerContainer>
{
    private readonly SqlServerContainer _container;
    private readonly OrderRepository _orderRepository;

    public OrderRepositoryIntegrationTests(SqlServerContainer container)
    {
        _container = container;
        
        // Configure DbContext to use the SQL Server container connection
        var options = new DbContextOptionsBuilder<OrderDbContext>()
            .UseSqlServer(_container.ConnectionString)
            .Options;

        var dbContext = new OrderDbContext(options);
        
        // Initialize the repository with the DbContext
        _orderRepository = new OrderRepository(dbContext);
    }

    [Fact]
    public async Task ShouldPersistOrderWhenUserBuy()
    {
        // Arrange: Create a new order with a unique ID
        var orderId = Guid.NewGuid();
        var order = new Order
        {
            Id = orderId,
            CustomerId = Guid.NewGuid(),
            CreatedAt = DateTime.UtcNow
        };

        // Act: Persist the order and retrieve it back
        await _orderRepository.CreateAsync(order);
        var retrievedOrder = await _orderRepository.GetByIdAsync(orderId);

        // Assert: Ensure the retrieved order matches the one that was saved
        Assert.NotNull(retrievedOrder);
        Assert.Equal(orderId, retrievedOrder.Id);
        Assert.Equal(order.CustomerId, retrievedOrder.CustomerId);
    }
}

A best practice is to use the IClassFixture pattern. IClassFixture is a feature of xUnit that enables resource sharing across multiple tests within the same test class.

Instead of creating and destroying the test infrastructure for each individual test, which could be time-consuming with Test Containers, IClassFixture ensures that the fixture (in our case, the SQL Server container) is created once before any tests in the class run and disposed of after all tests complete. This is particularly valuable when working with containers because it significantly reduces test execution time - rather than spinning up and tearing down a database container for each test, all tests in the class share the same container instance.

Using tags (Trait) also allows better organization and categorization of tests, facilitating their selective execution in CI/CD pipelines.

Conclusion

Test Containers represent a major advancement in .NET integration testing, combining the ease of use of in-memory solutions with the reliability of testing on real components.

While they present some constraints such as longer startup times and Docker dependency, their advantages far outweigh these limitations. Perfect isolation, production environment fidelity, and complete automation make them a future-proof solution for integration testing.

The constant evolution of the Test Containers ecosystem, with regular addition of new modules and performance improvements, confirms their position as a de facto standard for robust integration testing in .NET.

This article is the first in a series dedicated to Test Containers in C#. In upcoming articles, we'll explore more advanced concepts: why these tests are crucial in a modern development environment, how to handle complex scenarios (multiple containers, custom networks, persistent volumes), and how to effectively integrate Test Containers into your CI/CD pipelines. Stay tuned to deepen your mastery of this powerful tool.

Have a goat day 🐐

GitHub - csannierfr/Goat-Review-TestContainers
Contribute to csannierfr/Goat-Review-TestContainers development by creating an account on GitHub.


Join the conversation.

Great! Check your inbox and click the link
Great! Next, complete checkout for full access to Goat Review
Welcome back! You've successfully signed in
You've successfully subscribed to Goat Review
Success! Your account is fully activated, you now have access to all content
Success! Your billing info has been updated
Your billing was not updated