Testcontainers for C# and .NET: Simplify Integration Tests with Docker
What exactly is Testcontainers?
Testcontainers is an open-source library designed to make integration testing more manageable and reliable by using Docker containers to spin up “throwaway” instances of various services. It allows developers to create lightweight, temporary versions of databases, message brokers, web browsers, and more, using real infrastructure instead of mocks or in-memory simulators. Think of Testcontainers as a way to access full-featured services without any permanent setup, all within the scope of your tests.
With Testcontainers, you can set up dependencies like a database or message queue just for the duration of a test, ensuring a fresh start for each test. These instances are automatically created when a test begins and torn down once it’s complete, which means you’re always working with a clean environment. This ability to use real service instances allows you to catch subtle bugs that might otherwise go unnoticed with in-memory databases or mocked dependencies, which often lack certain real-world behaviors.
What makes Testcontainers so versatile is its wide compatibility across languages (Java, C#, Python, Go, and more) and popular testing frameworks, making it a highly adaptable solution for different environments and use cases. Once Docker is set up on your machine or CI/CD pipeline, Testcontainers seamlessly integrates with your tests, adding only a few lines of configuration to launch services that match your production setup.
Why should developers use Testcontainers?
Integration testing is often seen as “difficult” because of the effort required to set up and maintain a reliable testing environment. Without Testcontainers, integration tests usually need an actual database, cache, or other service up and running before the tests begin. Not only must this infrastructure be available, but it also needs to be properly configured, often with data seeded to a specific state required for each test. If multiple tests run concurrently on the same database or service, they might conflict with each other, leading to inconsistent and unreliable results.
To work around these challenges, some teams use in-memory databases or simplified versions of their dependencies. While this approach might seem easier, it often falls short: in-memory implementations typically lack key features or behaviors found in production systems, similar to the limitations of mock objects. This means that while in-memory testing can verify basic functionality, it might miss critical issues or incompatibilities that would only surface with a real, production-grade service.
With Testcontainers, you’re free from the burden of manually managing testing infrastructure. All you need is Docker installed on your system or CI/CD environment. By automating container setup and teardown, Testcontainers provides an efficient and repeatable approach to integration testing, allowing you to use actual services with confidence. This leads to high-quality tests that closely reflect real-world scenarios, improving test reliability and boosting your overall confidence in the application’s behavior.
It includes over 50 preconfigured modules, covering popular dependencies like SQL Server, MongoDB, Redis, MySql and more. These modules make it even easier to set up real infrastructure in your tests without complex configurations. Instead of manually setting up each service, you can simply select a module, specify basic settings, and instantly have an isolated, fully functional instance tailored to your needs.
Example: Using MySql with Testcontainers in a .NET Minimal API
Disclaimer: This example is intended for educational purposes only. While it demonstrates key concepts, there are more efficient ways to write and optimize this code in real-world applications. Consider this a starting point for learning, and always aim to follow best practices and refine your implementation.
Prerequisites
Before starting, make sure you have the following installed:
C# Extension for VSCode: Install the C# extension for VSCode to enable C# support.
.NET SDK: Download and install the .NET SDK if you haven’t already.
Visual Studio Code (VSCode): Install Visual Studio Code for a lightweight code editor.
Docker Client: Install Docker Desktop.
Overview of the SQL Server Testcontainers Integration
1. Setting Up the API with MySql
We’ll use a minimal API project that interacts with MySql through a simple data model. Here’s an example model to work with, a UserService
that will manage user data.
2. Testcontainers with MySql
Testcontainers will allow us to run MySql in a Docker container specifically for our tests. This approach is lightweight, doesn’t require a test database instance setup, and isolates each test run, so tests don’t interfere with each other.
3. Create a Minimal API project
Using VSCode let’s create a ASP.NET Core Web API project named TestcontainersExample.
dotnet new web -o TestcontainersExample
cd TestcontainersExample
4. Install the required packages
dotnet add package Testcontainers
dotnet add package Testcontainers.MySql
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Pomelo.EntityFrameworkCore.MySql
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Microsoft.NET.Test.Sdk
5. Code Implementation
Here’s how your Program.cs
should look:
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// Configure the MySQL Database Context
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseMySql(builder.Configuration.GetConnectionString("DefaultConnection"),
new MySqlServerVersion(new Version(8, 0, 21))));
builder.Services.AddScoped<IUserService, UserService>();
var app = builder.Build();
// Define API Endpoint
app.MapGet("/users", async (IUserService userService) =>
{
var users = await userService.GetUsers();
return Results.Ok(users);
});
app.Run();
// Application DbContext
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<User> Users => Set<User>();
}
// Entity Model
public class User
{
public int Id { get; set; }
public string Name { get; set; }
}
// Service Interface for User Management
public interface IUserService
{
Task<List<User>> GetUsers();
}
// Service Implementation for User Management
public class UserService : IUserService
{
private readonly AppDbContext _context;
public UserService(AppDbContext context) => _context = context;
public async Task<List<User>> GetUsers() => await _context.Users.ToListAsync();
}
6. Configure appsettings.json
Add a MySQL connection string in appsettings.json
. This will be used for local development, while the integration tests will override this with a Testcontainers instance.
{
"ConnectionStrings": {
"DefaultConnection": "YOUR_CONNECTION_STRING"
}
}
7. Implement the Integration Test
Here’s the code for UserIntegrationTests.cs
, which uses Testcontainers to spin up a MySQL container dynamically. It then initializes the database and verifies that user data can be saved and retrieved.
using Microsoft.EntityFrameworkCore;
using Testcontainers.MySql;
using Xunit;
namespace TestcontainersExample;
public class UserIntegrationTests : IAsyncLifetime
{
private readonly MySqlContainer _dbContainer;
private AppDbContext _dbContext;
public UserIntegrationTests()
{
_dbContainer = new MySqlBuilder()
.WithImage("mysql:8.0")
.Build();
}
public async Task InitializeAsync()
{
await _dbContainer.StartAsync();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseMySql(_dbContainer.GetConnectionString(),new MySqlServerVersion(new Version(8, 0, 21)))
.Options;
_dbContext = new AppDbContext(options);
await _dbContext.Database.EnsureCreatedAsync();
}
public async Task DisposeAsync()
{
await _dbContext.DisposeAsync();
await _dbContainer.StopAsync();
}
[Fact]
public async Task AddUser_ShouldAddUserToDatabase()
{
// Arrange
var user = new User { Name = "Test User" };
// Act
_dbContext.Users.Add(user);
await _dbContext.SaveChangesAsync();
// Assert
var users = await _dbContext.Users.ToListAsync();
Assert.Single(users);
Assert.Equal("Test User", users[0].Name);
}
}
7. Run the Test
dotnet test
Explanation of the Integration Test Code
- Test Initialization:
- The
InitializeAsync
method starts a MySQL container using Testcontainers and configures theAppDbContext
to connect to this container.
- The
- Database Setup:
EnsureCreatedAsync
ensures the database structure matches theAppDbContext
schema before each test.
- Test Execution:
- The test
AddUser_ShouldAddUserToDatabase
verifies that an added user is correctly saved and can be retrieved from the database.
- The test
- Cleanup:
DisposeAsync
disposes ofDbContext
and stops the container after each test, ensuring isolated, repeatable tests.
With this setup, you can confidently test your API’s database interactions in a controlled environment without needing a local database installation, thanks to Testcontainers and Docker.
Why Use WebApplicationFactory with a Custom Database Container?
For scenarios where simulating a full application environment is critical such as API testing ASP.NET Core’s WebApplicationFactory
allows developers to spin up a test server and configure a real database container for end-to-end integration tests. By using WebApplicationFactory
with a custom container, such as MySqlContainer
, you can create a truly isolated and production-like environment. This ensures each test class operates with its dedicated database instance, avoiding cross-test data contamination. Additionally, if your application heavily depends on specific configurations or services, this approach provides a great balance of realism and automation.
Conclusion: The Benefits of Using Testcontainers for Integration Testing
Testcontainers has proven to be an invaluable tool for developers aiming to deliver high-quality, resilient software. It simplifies integration testing by allowing real instances of dependencies, such as databases and message brokers, to be spun up in isolated containers directly from the test suite. This approach offers significant advantages over traditional mock-based testing, where mocks often fail to replicate the nuanced behaviors of real-world applications.
By integrating containers within the test process, Testcontainers allows for more realistic and reliable testing without the complexity of setting up separate infrastructure or managing shared resources. Developers can easily access a fully configured environment tailored to the needs of each test, whether working locally or within a CI/CD pipeline that supports Docker.
Incorporating Testcontainers into a .NET testing environment with services like MySQL or Redis allows developers to simulate real interactions, improve integration test coverage, and, ultimately, enhance overall code quality. As software demands robust and reliable infrastructure, tools like Testcontainers play a crucial role in creating resilient testing frameworks.
By following a Testcontainers approach, developers can maintain a high standard for testing, align with best practices in CI/CD, and build confidence in the systems they deploy. Testcontainers’ approach is a step toward a truly dependable testing environment, ensuring that code works as expected both in development and in production.
If you think your friends or network would find this article useful, please consider sharing it with them. Your support is greatly appreciated.
Thanks for reading!