December 22, 2022

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.

Joy of Test Containers
Figure 1. Joy of Test Containers

First, install the TestContainers library via NuGet. Then, configure your first container. For example for Microsoft SqlServer:

Configure SQL Server:
// 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.

Using the database:
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 ;)

Tags: C# Development