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 :
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
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 🐐
Join the conversation.