How to Use TestContainers in .Net
At some point in your development lifecycle, you will need to test that your system can integrate with another system, whether it be another API, a database, or caching service, for example. This can be a laborious task of spinning up other servers h...

At some point in your development lifecycle, you will need to test that your system can integrate with another system, whether it be another API, a database, or caching service, for example. This can be a laborious task of spinning up other servers hosting the 3rd party API replica, or permanently hosting a SQL database seeded with test data.
In this article, I’ll teach you how to use the TestContainers library to make running integration tests much easier and more manageable.
Table of Contents
Prerequisites
Understanding of Docker
Understanding of xUnit and testing
Installation of the following packages:
TestContainers
TestContainers.MsSql
xUnit
>= .Net 8
FluentAssertions
Microsoft.Data.SqlClient
What Is TestContainers?
TestContainers is an open source library that provides you with easily disposable container instances for things like database hosting, message brokers, browsers and more – basically anything that can run in a Docker container.
It removes the necessity to maintain hosted environments for testing in the cloud or on local machines. As long as the user’s machine and CI/CD host supports Docker, the testContainer tests can easily be run.
How Does It All Work?
You define the image you’re wanting to utilise, and specify a configuration.
The TestContainer library spins up a Docker Container with the configured image.
Provides Connection Details
After starting the container, TestContainers exposes connection strings (for example, a database connection URL), so your tests can use the real service, rather than having to configure this yourself.
Cleans Up Automatically
When the test finishes, TestContainers removes the container automatically, ensuring no leftover resources. This is one of the best things about using TestContainers: all the creation, tear down, and container setup is handled within the library itself, making it perfect for use within delivery pipelines.
How to Set Up Your First Test
For the purpose of this tutorial, we’re going to keep things simple, and only use a MS Sql Server
image.
The first thing we’re going to do is configure our Microsoft SQL Server Docker container via the TestContainer fluid API.
Create your test class like below:
public class IntegrationTests: IAsyncLifetime
{
private MsSqlContainer _container;
private FakeLogger _logger
public async Task InitializeAsync()
{
_container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("P@ssw0rd123")
.WithPortBinding(1443)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433))
.Build();
_logger = new FakeLogger();
}
public async Task DisposeAsync() => await _container.DisposeAsync();
}
Here we’re using xUnit’s IAsyncLifetime
interface. It’s an interface in xUnit that provides a way to handle async setup and teardown for test classes. It's useful when you need to initialise and clean up resources asynchronously. We’re using the InitializeAsync()
to setup and define our Microsoft SQL Database container as well as starting the container, then using the DisposeAsync()
method to stop and dispose of our container.
Explanation of Builder Methods
WithImage()
: this allows us to specify the image we want Docker to pull down and run. We’ve opted for the latest version of SQL Server 2022.WithPassword()
: This allows us to specify the password for the database (when creating most databases, a password is normally required).WithPortBinding()
: This allows us to specify both the hosting port number on your machine, as well as the container port numberWithWaitStrategy()
: Here we can specify a wait strategy, which informs our container to wait for a condition before the container is ready to use. This is important because some services (like databases or APIs) take time to fully start up.Build()
" This is the command that builds the test container based on the configuration. This does not run or start the container – you can do this using thecontainer.StartAsync()
method as mentioned previously.
Why Is WithWaitStrategy()
Needed?
By default, TestContainers assumes the container is ready as soon as it starts running. But some services might:
Take time to initialize.
Require a specific log message before they are ready.
Need a port to be accessible before you can connect.
Using WithWaitStrategy()
, you can customise how TestContainers waits before considering the container "ready."
Adding the Test
public class IntegrationTests: IAsyncLifetime
{
private MsSqlContainer _container;
private FakeLoger _logger;
public async Task InitializeAsync()
{
_container = new MsSqlBuilder()
.WithImage("mcr.microsoft.com/mssql/server:2022-latest")
.WithPassword("P@ssw0rd123")
.WithPortBinding(1443)
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(1433))
.Build();
await _container.StartAsync();
_logger = new FakeLogger();
}
public async Task DisposeAsync() => await _container.DisposeAsync();
[Fact]
public async Task Test_Database_Connection()
{
var connectionString = _container.GetConnectionString();
using var conn = new SqlConnection(connectionString);
await conn.OpenAsync();
Assert.True(conn.State == System.Data.ConnectionState.Open);
}
}
The above test, although it’s simple, illustrates how easy it is to spin up a container and create a simple test. The above test will work, but it can lead to low performing tests and high usage of machine resource when not used correctly. Let me explain:
Using IAsyncLifetime
is necessary, as we’re calling async setup methods (StartAsync
), for example. But the InitializeAsync() / DisposeAsync()
methods when situated in a test class are run before and after every test (Fact
in xUnit).
This means that every time a test begins, it is:
creating a brand new Docker container,
pulling the MS Sql image,
creating the DB,
running the tests, and
tearing down the container.
You can test this by copying and pasting the above Test_Database_Connection()
test multiple times, adding a number to each duplicate test (to keep the compiler happy), and opening Docker Desktop. Running all the tests, you will see a new container (with a different name) being created for each test run.
Now, this can be acceptable if you have a limited number of tests in your test class. But it can have negative outcomes on test classes with a larger number of tests, meaning test maintenance and planning is key. It’s useful, though, when you want to make sure that the database is in a completely clean state before each test, ensuring no data contamination from other tests running.
Key Behaviors of IAsyncLifetime
in a Test Class
When your test class implements IAsyncLifetime
, xUnit's default behaviour is:
1. Creates a new instance of the test class for each test method.
2. Calls InitializeAsync()
before each test.
3. Calls DisposeAsync()
after each test.
What Does This Mean for TestContainers?
In our case, since
InitializeAsync()
sets up a new container, a new container is created for each test.DisposeAsync()
stops the container after each test finishes.Ensures a completely fresh database state for every test, avoiding data contamination.
Is slow and resource-intensive, especially if you have many test methods.
A more visual look on a test class could look like this: