Skip to main content

by: Andre Hofmeister

I discovered Testcontainers several years ago while a colleague prepared a conference talk and I was astonished by its simplicity. Compared to our test infrastructure back then, Testcontainers for Java made a lot of things easier, faster, and even more reliable at the same time. Since then, I got more and more engaged with the idea of running tests with or within throwaway instances of containers.

Sometime later, I worked on a .NET application that had been around for many years and was loved by our customers. The test coverage was not great though. Due to a lot of dependencies (external services, hardware, etc.), it was difficult to write tests, especially tests that run concurrently in CI builds. Our mocks helped a lot, but I was not happy with them.

In general, mocks are expensive to maintain and do not always behave like the actual third-party services. It was then that I remembered about Testcontainers.

Although back then Testcontainers for .NET did not contain all the necessary functionalities, after a couple of adjustments it was already enough for a first proof of concept. From now on, I was able to replace mocks with containers and more and more tests could finally also run in CI builds. This was the beginning of today’s Testcontainers for .NET. Over the years I kept adding features and fixing issues reported by the community until I became a core maintainer of the project and finally a part of AtomicJar.

Now Testcontainers for .NET is one of the fastest growing Testcontainers implementations lately and is loved by many developers. In the past 3 months, NuGet has downloaded the dependency more than 170k times. More and more developers start to use, follow and contribute to the testcontainers-dotnet repository.

GitHub’s testcontainers-dotnet stargazers history.

To be a reliable partner for any kind of development, Testcontainers for .NET takes testing seriously. Almost 95% of the OSS project is covered by tests. It makes heavy use of itself to ensure even complex third party modules run as expected.

It is easy to set up Docker containers or any other Docker resources within tests using a Testcontainers implementation. Testcontainers for .NET provides a builder pattern to configure, create and delete Docker resources in .NET. It offers several ways to interact or exchange data with resources and integrates seamlessly into any .NET test framework. Here is how you can use it with xUnit.net.

Testcontainers ⨯ xUnit.net

No matter if your tests require databases, message brokers, your own services or even a running instance of your entire application, leveraging Testcontainers in your tests means you can set up the infrastructure fast and reliably. You can also run tests in parallel against multiple lightweight or a single shared heavyweight instance, depending on the use case. xUnit.net’s shared context offers several methods to access resources efficiently among different tests and scopes.

The following example adds tests to our ASP.NET Core Blazor application. The tests cover the web front-end including the REST API of our weather forecast application. Testcontainers for .NET builds and ships our app in a Docker image, runs it in a Docker container, orchestrates the necessary resources and executes the tests against it. This setup includes a Microsoft SQL Server to persist data and covers a common use case among many productive .NET applications. You find the entire example in the testcontainers-dotnet repository.

The WeatherForecastContainer class configures in the default constructor all dependencies to start the container that hosts our application.

const string weatherForecastStorage = "weatherForecastStorage";

var mssqlConfiguration = new MsSqlTestcontainerConfiguration();
mssqlConfiguration.Password = Guid.NewGuid().ToString("D");
mssqlConfiguration.Database = Guid.NewGuid().ToString("D");

var connectionString = $"server={weatherForecastStorage};user id=sa;password={mssqlConfiguration.Password};database={mssqlConfiguration.Database}";

_weatherForecastNetwork = new TestcontainersNetworkBuilder()
  .WithName(Guid.NewGuid().ToString("D"))
  .Build();

_mssqlContainer = new TestcontainersBuilder<MsSqlTestcontainer>()
  .WithDatabase(mssqlConfiguration)
  .WithNetwork(_weatherForecastNetwork)
  .WithNetworkAliases(weatherForecastStorage)
  .Build();

_weatherForecastContainer = new TestcontainersBuilder<TestcontainersContainer>()
  .WithImage(Image)
  .WithNetwork(_weatherForecastNetwork)
  .WithPortBinding(WeatherForecastImage.HttpsPort, true)
  .WithEnvironment("ASPNETCORE_URLS", "https://+")
  .WithEnvironment("ASPNETCORE_Kestrel__Certificates__Default__Path", WeatherForecastImage.CertificateFilePath)
  .WithEnvironment("ASPNETCORE_Kestrel__Certificates__Default__Password", WeatherForecastImage.CertificatePassword)
  .WithEnvironment("ConnectionStrings__DefaultConnection", connectionString)
  .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(WeatherForecastImage.HttpsPort))
  .Build();

First, the class configures the Docker network. The application and database use this private network to communicate with each other. Followed by that, the class sets up the configuration of the Microsoft SQL Server container. Once all the dependencies are in place, the example configures our weather forecast application conveniently with environment variables. xUnit.net calls IAsyncLifetime.InitializeAsync immediately after the class has been created. Our test project uses this mechanism to create and start all dependencies before any test run:

await Image.InitializeAsync()
  .ConfigureAwait(false);

await _weatherForecastNetwork.CreateAsync()
  .ConfigureAwait(false);

await _mssqlContainer.StartAsync()
  .ConfigureAwait(false);

await _weatherForecastContainer.StartAsync()
  .ConfigureAwait(false);

xUnit.net passes the WeatherForecastContainer class fixture instance to the test class WeatherForecastTest. Each test collection Api and Web creates a new instance of WeatherForecastContainer and spins up an isolated environment. Even multiple test sessions do not interfere with each other. To run tests against a single shared heavyweight instance (collection fixture), add all dependencies to a collection definition. This works not only for containers, but also for any other Docker resource like images, networks or volumes.

As soon as the container is up and our application is running, each test sends an HTTP request to our weather forecast application and validates the REST or web front-end response. To visualize the web front-end, Selenium takes a screenshot right before and after the test.

Selenium screenshot before and after the test.

A few years ago, I was able to add tests gradually to a legacy .NET application by using Testcontainers and the concept of containerized tests. The example above follows the same concept to test a modern ASP.NET Core Blazor application. Testcontainers integrates well in many test environments and increases the quality of the product a lot. Another great way to use it that is getting more popular lately is straight in development. You can use Testcontainers to start the necessary containers within your implementation while developing and no longer bother around with the infrastructure anymore. All of this works of course with Testcontainesr Cloud too. All things considered I believe that Testcontainers significantly improves the development experience and productivity and I am happy to continue working on such a vibrant open source project!

If you have any questions about Testcontainers for .NET or otherwise or in general about integration tests, we are looking forward to seeing you in our Slack workspace and be a part of the awesome Testcontainers community.

Oleg Šelajev

Oleg Šelajev is a developer advocate at AtomicJar working on making integration tests with Testcontainers better for everyone in the community. VirtualJUG leader. In 2017 became a Java Champion.