Effortless .NET Integration Testing - Introducing TestContainers
Writing integration tests doesn't have to be hard. Learn how the .NET library can help you use Docker to power your tests.
Introduction
During my years in development, writing integration tests are usually cumbersome and hard.
Just to mention the pain points of traditional integration testing:
Now, picture a world where your integration tests run seamlessly, thanks to the synergy of .NET and Docker. This is what this article is all about!
Tests that run on containers
This is where Docker steps in. By containerizing your testing environment, you achieve consistent, isolated setups for every test run. Running containers is flexible and good way to achieve, otherwise too difficult, isolation and repeatability of the tests.
Of course, as with everything, setting up Docker can be cumbersome:
All this complicate things and discourages an ordinary developer to write tests - even when Docker is around.
TestContainers library
TestContainers.NET is a handy tool for integration testing in .NET. It allows you to start and stop Docker containers directly from your test code, simplifying the setup of dependencies like databases and other services. By running your tests in isolated containers, you avoid conflicts and ensure consistency across different environments. This setup helps you avoid the "works on my machine" problem by providing a reproducible and consistent environment for each test run. With containers, your tests run faster and more efficiently, allowing for parallel execution without any worries about interfering states.
Here’s a quick example of how to use TestContainers in your tests:
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Xunit;
public class MyIntegrationTests
{
[Fact]
public async Task TestDatabaseConnection()
{
var dbContainer = new ContainerBuilder()
.WithImage("mcr.microsoft.com/mssql/server")
.WithPortBinding(1433)
.Build();
await dbContainer.StartAsync();
// Your test logic here
await dbContainer.StopAsync();
}
}
Here is some docus regarding this library:
If you're using xUnit test framework, you can use IClassFixture and ICollectionFixture helper interfaces to further optimize your code. This is how my initial integration test project structure, based on TestContainers and fixtures, looks like:
For instance, I'm using a Fixture-based class that implements IAsyncLifetime interface to initialize itself asynchronously. This class initialize a container running MS SQL Server instance, configures database, and prepares database context class by using EF Core:
using Microsoft.Data.SqlClient;
using Testcontainers.MsSql;
namespace MyProject.IntegrationTests.Configuration;
public sealed class TestMsSqlContainerFixture : IAsyncLifetime
{
private readonly MsSqlContainer _container;
private const string DatabaseName = "MyProjectDB";
public string ConnectionString { get; init; }
public TestMsSqlContainerFixture()
{
_container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithCleanUp(true)
.Build();
_container.StartAsync().Wait();
using (var connection = new SqlConnection(_container.GetConnectionString()))
{
connection.Open();
using var command = new SqlCommand($"CREATE DATABASE {DatabaseName};", connection);
command.ExecuteNonQuery();
}
ConnectionString = new SqlConnectionStringBuilder(_container.GetConnectionString())
{ InitialCatalog = DatabaseName }.ToString();
}
public async Task InitializeAsync()
{
await Task.Run(() =>
{
var configuration = TestHelper.GetConfiguration();
using var context = TestHelper.GetDbContext(ConnectionString);
DbInitializer.CreateSchemaAndSeedData(context, configuration);
});
}
public async Task DisposeAsync()
{
await _container.StopAsync();
await _container.DisposeAsync();
}
}
I'm also using Collection and Trait test attributes to further make tests configurable - thus making them more isolated and filtered (see DevOps):
[Collection("Persistence Tests"), Trait("Environment", "Docker"), Trait("Container", "MsSql")]
public class MsSqlContainersTests(TestMsSqlContainerFixture fixture) : IClassFixture<TestMsSqlContainerFixture>
{
[Fact]
public async Task TestService_CreateAsync_OperationValid()
{
// Arrange
using var context = TestHelper.GetDbContext(fixture.ConnectionString);
...
Assert.True(context.ChangeTracker.HasChanges());
context.SaveChanges();
Assert.False(context.ChangeTracker.HasChanges());
...
// Act
var result = await service.SaveAsync(model);
// Assert
...
Test execution is in minutes, but it works - the only thing you should keep an eye is memory and CPU consumption! The rest (starting and tearing down containers) is done for you! :)
DevOps integration
The great part of this story is that these tests, although written as integration tests, may be part of the build process - which is the thing specific to the unit tests - as they don't have any dependencies to the physical resources. In other words: as everything is containerized - there are no need for preparing and reconfiguring real resources!
TestContainers easily integrate with Azure Pipelines, GitHub Actions, or GitLab CI/CD cycle:
The tests are being integrated with Azure DevOps process, and I included it into an existing build pipeline with the standard .NET Core pipeline task:
One to notice though is that I separated the execution of integration tests by Trait attribute. As forementioned, this is a good principle for filtering tests, especially if memory and CPU consumption is critical. For instance, Azurite containers use up to 100 GB of memory, which is quite problematic, so I run this tests sequential and separate of other types of tests.
Summary
Using TestContainers library for integration tests in .NET offers several advantages over relying solely on Docker or local resources.
Firstly, TestContainers simplifies the setup and teardown of Docker containers directly within your test code. This eliminates the need for separate scripts or manual configuration, making it easier and faster to define and manage test environments. Each test runs in a clean, isolated container, ensuring consistent and reproducible results, which reduces the "works on my machine" problem.
Secondly, TestContainers automates the lifecycle of Docker containers. It starts containers before tests run and ensures they are properly cleaned up afterward. This means you don’t have to worry about lingering states or resources, leading to more reliable tests and fewer surprises.
Furthermore, by using TestContainers, you can easily integrate multiple services (like databases, message brokers, etc.) into your tests without complex orchestration. It’s ideal for testing interactions between various components of your system.
In short, TestContainers makes integration testing more straightforward, reliable, and scalable, leveraging the benefits of Docker while abstracting away much of its complexity.
This allows you to focus on writing meaningful tests rather than managing infrastructure. So, the developers (and/or QA personnel) are more productive and they keep their focus.
I hope this gave you a brief but useful overview about using TestContainers library in .NET for writing integration tests. Kind regards!
Turn Coffee into code
4 个月Thanks for sharing. I've used Testcontainers several times and have been very satisfied. Running tests locally is very easy and they behave like the target environment. Using testcontainers avoids issues between Windows/Linux differences and issues about different culture settings (like DateTime).