Test Containers for C#/.NET
This post is part of C# Advent Calendar 2022.
Many applications lean heavily on relation database. Your application uses complex queries, constrains on data and all more of the wonderful features of a relation database. That means that a lot of your applications behavior depends on how the database acts.
Therefore, I try to test an against actual database system.
Years ago this used to be a challenge, as most database systems where hard to setup and automate.
Today it is easy.
Use TestContainers!
Test Containers
TestContainers is a library which starts up Docker containers for your tests. More, it provides many pre-configured setups for databases.
First, install the TestContainers library via NuGet. Then, configure your first container. For example for Microsoft SqlServer:
// Configure the database you want to create
var dbConfig = new MsSqlTestcontainerConfiguration
{
Password = "Test1234",
Database = "TestDB"
};
// Then, create a container with that config
var testContainer = new TestcontainersBuilder<MsSqlTestcontainer>()
.WithDatabase(dbConfig)
// If image is not specified, the 'MsSqlTestcontainerConfiguration' will choose some default.
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.Build();
// And then start it:
testContainer.StartAsync().Wait();
This code will download the MS Server docker image and start a container.
Then ask for connection string for this container and run tests against it.
In this example I insert a simple entry into a dogs
table and the read it back.
The test will surprisingly fail! Guess why? I’ll give a hint at the end of this blog post.
class Dog
{
public DateTime BirthDate { get; set; }
public string Name { get; set; }
}
using (var db = new SqlConnection(testContainer.ConnectionString))
{
// I'm using dapper here for this blog examples
db.Execute(@"CREATE TABLE Dogs(
BirthDate DATETIME NOT NULL,
Name VARCHAR(MAX) NOT NULL
)");
var bornAt = new DateTime(2022, 12, 22, 12, 59, 59, 999);
db.Execute(@"INSERT Dogs(BirthDate,Name) VALUES(@BirthDate, @Name)", new
{
BirthDate = bornAt,
Name = "Joe"
});
var dog = db.Query<Dog>(@"SELECT * FROM Dogs").First();
// This assert will fail? Guess why? That is why I test against a real database implementation
Assert.AreEqual(bornAt, dog.BirthDate);
}
Clean up the container by calling the .DisposeAsync() method:
// Cleanup the container
testContainer.DisposeAsync();
Test Containers Are Cleaned Up
Now, while developing and testing you may kill the process which runs the tests. What happens to the containers it created?
Well, start a test container, and check what is running:
roman@gamlor /tmp> docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
86a1da367f12 mcr.microsoft.com/mssql/server:2017-CU28-ubuntu-16.04 "/opt/mssql/bin/nonr…" 7 seconds ago Up 6 seconds 0.0.0.0:49180->1433/tcp, :::49180->1433/tcp busy_bohr
5cec99b15206 testcontainers/ryuk:0.3.4 "/app" 8 seconds ago Up 7 seconds 0.0.0.0:49179->8080/tcp, :::49179->8080/tcp testcontainers-ryuk-123cc31a-afe5-4a39-8c6e-b85bab760cad
You will see that it started actually two containers. Interesting!
Then kill the test process, so that it has no chance to call any cleanup code.
Repeatedly inspect the running containers with docker ps
.
Shortly after you kill the test process, the containers will be cleaned up again.
That is the magic of the TestContainers library.
The testcontainers/ryuk
container herds all the tests containers.
It stops itself and the test containers
if it looses communication with the test program.
Go Wild
First, TestContainers has built in support for many databases.
Search for subclasses of TestcontainerDatabase
.
Same goes for messages systems, which are subclasses of TestcontainerMessageBroker
.
Or you check the documentation with the list.
If the preconfigured containers do not work for you, you can start an arbitrary image with the basic test container:
var someContainer = new TestcontainersBuilder<TestcontainersContainer>()
.WithImage("some-image")
.WithEnvironment("USER", "test-user")
.WithCommand("-flag-one -flag-two")
.Build();
There are also wait strategies to ensure a container is started up and fully ready.
Summary
TestContainers make it easy to start up a fully fledged database for testing purposes. It is perfect for integration tests!
PS: So why did the assert fail? Why did the stored row have a different BirthDate? Well, the SQL Server datetime data type has nasty rounding rules. So, it can silently change your dates! Therefore, again: Test against a real database ;)