Testcontainers libraries make it easy to create reliable tests by allowing your unit tests to run with real dependencies. Anything that runs in containers can become a part of your tests with just a few lines of code: from databases and message brokers to Kubernetes clusters and cloud solutions for testing.
Flexible API and attention to detail like automatic cleanup and mapped ports randomization made Testcontainers a widely-adopted solution. Still, one thing that elevates Testcontainers even more is the ecosystem of modules — pre-configured abstractions that allow you to test applications with specific technologies without configuring the containers yourself.
And now, with the recent Testcontainers for .NET release, there’s better support for modules than ever before.
In this article, we’ll look at how to create a Testcontainers for .NET module for your favorite technology, how to add capabilities to the module so common configuration options are added in the API, and where to look for a good example of a module.
How to implement a module for Testcontainers for .NET
Testcontainers for .NET offers two ways of implementing a module, depending on the complexity of the use case. For simple modules, developers can inherit from the ContainerBuilder
class. It provides a straightforward way to build a module and configure it as needed.
For more advanced use cases, Testcontainers for .NET provides a second option for developers to inherit from ContainerBuilder<TBuilderEntity, TContainerEntity, TConfigurationEntity>
. This class offers a more flexible and powerful way to build modules and provides access to additional features and configurations.
Both approaches allow developers to share and reuse their configurations and best practices. They’re also a simple and consistent way to spin up containers.
The Testcontainers for .NET repository contains a .NET template to scaffold advanced modules quickly. To create and add a new module to the Testcontainers solution file, check out the repository and install the .NET template first:
git clone --branch develop [email protected]:testcontainers/testcontainers-dotnet.git
cd ./testcontainers-dotnet/
dotnet new --install ./src/Templates
The following CLI commands create and add a new PostgreSQL module to the solution file:
dotnet new tcm --name PostgreSql --official-module true --output ./src
dotnet sln add ./src/Testcontainers.PostgreSql/Testcontainers.PostgreSql.csproj
A module in Testcontainers for .NET typically consists of three classes representing the builder, configuration, and container. The PostgreSQL module we just created above consists of the PostgreSqlBuilder
, PostgreSqlConfiguration
, and PostgreSqlContainer
classes.
- The builder class sets the module default configuration and validates it. It extends the Testcontainers builder and adds or overrides members specifically to configure the module. The builder is responsible for creating a valid configuration and container instance.
- The configuration class stores optional members to configure the module and interact with the container. Usually, these are properties like a
Username
orPassword
that are required sometime later. - Developers interact with the builder the most. It manages the lifecycle and provides module specific members to interact with the container. The result of the builder is an instance of the container class.
The next steps guide you through the process of creating a new module for Testcontainers for .NET. We’ll first show how to override and extend the default configuration provided by the ContainerBuilder
class.
After that, we’ll explain how to add new members to the builder and configuration classes. By doing this, you extend the capabilities of the builder and configuration to support more complex use cases.
Set module configuration
The configuration classes in Testcontainers for .NET are designed to be immutable. In other words, once an instance of a configuration class is created, its values cannot be changed. This makes it more reliable, easier to understand, and better to share between different use cases like A/B testing.
To set the PostgreSQL module default configuration, override the read-only DockerResourceConfiguration
property in PostgreSqlBuilder
and set its value in both constructors. The default constructor sets DockerResourceConfiguration
to the return value of Init().DockerResourceConfiguration
, where the overloaded private constructor just sets the argument value. It receives an updated instance of the immutable Docker resource configuration as soon as a property changes. The .NET template already includes this configuration, making it easy for developers to quickly get started by simply commenting out the necessary parts.
public PostgreSqlBuilder()
: this(new PostgreSqlConfiguration())
{
DockerResourceConfiguration = Init().DockerResourceConfiguration;
}
private PostgreSqlBuilder(PostgreSqlConfiguration resourceConfiguration)
: base(resourceConfiguration)
{
DockerResourceConfiguration = resourceConfiguration;
}
protected override PostgreSqlConfiguration DockerResourceConfiguration { get; }
To append the PostgreSQL configurations to the default Testcontainers configurations, override or comment out the member Init()
. Then, add the necessary configurations, such as the Docker image and a wait strategy to the base implementation.
protected override PostgreSqlBuilder Init()
{
var waitStrategy = Wait.ForUnixContainer().UntilCommandIsCompleted("pg_isready");
return base.Init().WithImage("postgres:15.1").WithPortBinding(5432, true).WithWaitStrategy(waitStrategy);
}
Add module capability
When using the PostgreSQL Docker image, it’s required to have a password set in order to run it. To demonstrate how to add a new builder capability, we’ll use this requirement as an example.
First, add a new property Password
to the PostgreSqlConfiguration
class. Then, add a password argument with a default value of null to the default constructor.
This allows the builder to set individual arguments or configurations. The overloaded PostgreSqlConfiguration(PostgreSqlConfiguration, PostgreSqlConfiguration)
constructor takes care of merging the configurations together. The builder will receive and hold an updated instances that contains all information:
public PostgreSqlConfiguration(string password = null)
{
Password = password;
}
public PostgreSqlConfiguration(PostgreSqlConfiguration oldValue, PostgreSqlConfiguration newValue)
: base(oldValue, newValue)
{
Password = BuildConfiguration.Combine(oldValue.Password, newValue.Password);
}
public string Password { get; }
Since the PostgreSqlConfiguration
class is now able to store the password value, we can add a member WithPassword(string)
to PostgreSqlBuilder
. We don’t just store the password in the PostgreSqlConfiguration
instance to construct the database connection string later. But we also set the necessary environment variable POSTGRES_PASSWORD
to run the container.
public PostgreSqlBuilder WithPassword(string password)
{
return Merge(DockerResourceConfiguration, new PostgreSqlConfiguration(password: password)).WithEnvironment("POSTGRES_PASSWORD", password);
}
By following this approach, the PostgreSqlContainer
class is able to access the configured values. This opens up additional functionalities, such as constructing the database connection string. This enables the class to provide a more streamlined and convenient experience for developers who are working with modules.
public string GetConnectionString()
{
var properties = new Dictionary<string, string>();
properties.Add("Host", Hostname);
properties.Add("Port", GetMappedPublicPort(5432).ToString());
properties.Add("Database", "postgres");
properties.Add("Username", "postgres");
properties.Add("Password", _configuration.Password);
return string.Join(";", properties.Select(property => string.Join("=", property.Key, property.Value)));
}
Finally, there’re two approaches to ensure that the required password is provided. Either override the Validate()
member and check the immutable configuration instance:
protected override void Validate()
{
base.Validate();
_ = Guard.Argument(DockerResourceConfiguration.Password, nameof(PostgreSqlConfiguration.Password))
.NotNull()
.NotEmpty();
}
or extend the Init()
member as we have already done and add WithPassword(Guid.NewGuid().ToString())
to set a default value.
It’s always a good idea to add both approaches. This way, the user can be sure that the module is properly configured, whether by themself or by default. This helps maintain a consistent and reliable experience for the user. Following it, when creating your own modules, either in-house or public, you can be a role model for other developers too.
The Testcontainers for .NET repository provides a reference implementation of the Microsoft SQL Server module. This module is a comprehensive example and can serve as a guide for you to get a better understanding of how to implement an entire module including the tests!
Conclusion
Testcontainers for .NET offers a streamlined and flexible way to spin up test dependencies. By utilizing the .NET template for the new modules, developers can take advantage of the pre-existing configurations and easily extend them with custom abstractions.
This helps to grow the ecosystem of the technologies you can use to test applications against with just a few lines of code. And this is made possible without requiring the end-developer to do the low-level configuration like specifying what ports to expose or paths to put the config files in the container.
Great use cases for the modules include public contributions to the Testcontainers for .NET project to support your favorite database or technology and also in-house abstractions to help your colleagues keep up with best practices.
All in all, by following the steps outlined in this article, you can easily extend the capabilities of Testcontainers for .NET and make the most out of their testing setup.
Learn more
- Sign up for a Testcontainers Cloud account.
- Connect on the Testcontainers Slack.
- Learn about Testcontainers best practices.
- Get started with the Testcontainers guide.
- Subscribe to the Docker Newsletter.
- Have questions? The Docker community is here to help.
- New to Docker? Get started.