Skip to content

Testing with TUnit

The Testcontainers.TUnit package simplifies writing tests with containers in TUnit. By leveraging TUnit's test lifecycle and injectable class data sources, this package automates the setup and teardown of test resources, creating and disposing of containers as needed. This reduces repetitive code and avoids common patterns that developers would otherwise need to implement repeatedly.

To get started, add the following dependency to your project file:

NuGet
1
dotnet add package Testcontainers.TUnit

Creating an isolated test context

To create a new test resource instance for each test, inherit from the ContainerTest<TBuilderEntity, TContainerEntity> class. TUnit creates a new instance of the test class for every test, so each test resource instance is isolated and not shared across other tests, making this approach ideal for destructive operations that could interfere with other tests. You can access the generic TContainerEntity container instance through the Container property.

The example below demonstrates how to override the Configure() method and pin the image version. This method allows you to configure the container instance specifically for your test case, with all container builder methods available. If your tests rely on a Testcontainers' module, the module's default configurations will be applied.

1
2
3
4
5
6
7
8
public sealed partial class RedisContainerTest : ContainerTest<RedisBuilder, RedisContainer>
{
    protected override RedisBuilder Configure()
    {
        // šŸ‘‡ Configure your container instance here.
        return new RedisBuilder("redis:7.0");
    }
}

Tip

Always pin the image version to avoid flakiness. This ensures consistency and prevents unexpected behavior, as the latest tag can point to a new version.

The base class automatically forwards Testcontainers' log messages to the output of the running test. Container startup honors the test's cancellation token, so a canceled or timed-out test does not leave a container start running in the background.

Considering that each test gets its own test resource instance (Redis container), retrieving the Redis (string) value in the second test will always return null, regardless of the order in which TUnit runs the tests.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
[Test]
[Property(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
public async Task Test1()
{
    // šŸ‘† A new container instance is created and started before this method (test) runs.
    using var redis = await ConnectionMultiplexer.ConnectAsync(Container.GetConnectionString());
    await redis.GetDatabase().StringSetAsync("key", "value");
    await Assert.That(redis.IsConnected).IsTrue();
    // šŸ‘‡ The created and started container is disposed of after this method (test) completes.
}

[Test]
[Property(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
public async Task Test2()
{
    // šŸ‘† A new container instance is created and started before this method (test) runs.
    using var redis = await ConnectionMultiplexer.ConnectAsync(Container.GetConnectionString());
    var redisValue = await redis.GetDatabase().StringGetAsync("key");
    await Assert.That(redisValue.IsNull).IsTrue();
    // šŸ‘‡ The created and started container is disposed of after this method (test) completes.
}

If you check the output of docker ps, you will notice that three container instances in total are run, with two of them being Redis instances.

List running containers
1
2
3
4
5
PS C:\Sources\dotnet\testcontainers-dotnet> docker ps
CONTAINER ID   IMAGE                       COMMAND                  CREATED
be115f3df138   redis:7.0                   "docker-entrypoint.s…"   3 seconds ago
59349127f8c0   redis:7.0                   "docker-entrypoint.s…"   4 seconds ago
45fa02b3e997   testcontainers/ryuk:0.14.0   "/bin/ryuk"             4 seconds ago

Creating a shared test context

Sometimes, creating and disposing of a test resource can be an expensive operation that you do not want to repeat for every test. By inheriting from the ContainerFixture<TBuilderEntity, TContainerEntity> class, you can share the test resource instance across all tests within the same test class, the same assembly, or even the entire test session.

1
2
3
4
5
6
7
8
[UsedImplicitly]
public sealed class RedisContainerFixture : ContainerFixture<RedisBuilder, RedisContainer>
{
    protected override RedisBuilder Configure()
    {
        return new RedisBuilder("redis:7.0");
    }
}

TUnit injects the fixture through the ClassDataSource<TFixture> attribute. The Shared argument controls the lifetime of the fixture: SharedType.PerClass creates the fixture once for the entire test class, SharedType.PerAssembly and SharedType.PerTestSession widen the scope accordingly, and SharedType.Keyed shares the fixture among all tests that use the same key. TUnit starts the container before the first test that uses the fixture runs and disposes of it after the last test in the chosen scope completes. Add the attribute to your test class and accept the fixture as a constructor parameter, or annotate a required property with it instead.

1
2
[ClassDataSource<RedisContainerFixture>(Shared = SharedType.PerClass)]
public sealed partial class RedisContainerTest(RedisContainerFixture fixture);

TUnit runs tests in parallel by default. In this case, retrieving the Redis (string) value in the second test depends on the value the first test adds. The DependsOn attribute ensures the second test does not start before the first one has finished, without sacrificing parallelism for the remaining tests. The Redis (string) value will therefore no longer be null; instead, it will return the value added in the first test.

1
2
3
4
5
6
7
8
9
[Test]
[DependsOn(nameof(Test1))]
[Property(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
public async Task Test2()
{
    using var redis = await ConnectionMultiplexer.ConnectAsync(fixture.Container.GetConnectionString());
    var redisValue = await redis.GetDatabase().StringGetAsync("key");
    await Assert.That(redisValue.ToString()).IsEqualTo("value");
}

The output of docker ps shows that, instead of two Redis containers, only one runs.

List running containers
1
2
3
4
PS C:\Sources\dotnet\testcontainers-dotnet> docker ps
CONTAINER ID   IMAGE                       COMMAND                  CREATED
d29a393816ce   redis:7.0                   "docker-entrypoint.s…"   3 seconds ago
e878f0b8f4bc   testcontainers/ryuk:0.14.0   "/bin/ryuk"             3 seconds ago

Note

TUnit sets injected properties before it initializes an instance. A fixture can therefore declare its own ClassDataSource<TFixture> properties (for example, a shared network or a dependent container) and use them inside Configure(). TUnit resolves the dependency graph, initializes the fixtures depth-first, and disposes of them in reverse order.

Testing ADO.NET services

In addition to the two mentioned base classes, the package contains two more classes: DbContainerTest and DbContainerFixture, which behave identically but offer additional convenient features when working with services accessible through an ADO.NET provider.

Inherit from either the DbContainerTest or DbContainerFixture class and override the Configure() method to configure your database service.

In this example, we use the default configuration of the PostgreSQL module. The container image capabilities are used to instantiate the database, schema, and test data. During startup, the PostgreSQL container runs SQL scripts placed under the /docker-entrypoint-initdb.d/ directory automatically.

1
2
3
4
5
6
7
8
public sealed partial class PostgreSqlContainerTest : DbContainerTest<PostgreSqlBuilder, PostgreSqlContainer>
{
    protected override PostgreSqlBuilder Configure()
    {
        return new PostgreSqlBuilder("postgres:15.1")
            .WithResourceMapping("Chinook_PostgreSql_AutoIncrementPKs.sql", "/docker-entrypoint-initdb.d/");
    }
}

Inheriting from the database container test or fixture class requires you to implement the abstract DbProviderFactory property and resolve a compatible DbProviderFactory according to your ADO.NET service.

1
2
public override DbProviderFactory DbProviderFactory
    => NpgsqlFactory.Instance;

Note

Depending on how you initialize and access the database, it may be necessary to override the ConnectionString property and replace the default database name with the one actual in use.

After configuring the dependent ADO.NET service, you can add the necessary tests. In this case, we run an SQL SELECT statement to retrieve the first record from the album table. TUnit injects the test's CancellationToken when the test method declares a parameter of that type.

1
2
3
4
5
6
7
8
9
[Test]
[Property(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
public async Task Test1(CancellationToken cancellationToken)
{
    const string sql = "SELECT title FROM album ORDER BY album_id";
    using var connection = await OpenConnectionAsync(cancellationToken);
    var title = await connection.QueryFirstAsync<string>(sql);
    await Assert.That(title).IsEqualTo("For Those About To Rock We Salute You");
}

To share a database container across tests, inherit from DbContainerFixture instead. The fixture implements the DbProviderFactory property and the Configure() method in one place.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
[UsedImplicitly]
public sealed class PostgreSqlContainerFixture : DbContainerFixture<PostgreSqlBuilder, PostgreSqlContainer>
{
    public override DbProviderFactory DbProviderFactory
        => NpgsqlFactory.Instance;

    public override string ConnectionString
    {
        get
        {
            var connectionStringBuilder = new NpgsqlConnectionStringBuilder(base.ConnectionString);
            connectionStringBuilder.Database = "chinook_auto_increment";
            return connectionStringBuilder.ConnectionString;
        }
    }

    protected override PostgreSqlBuilder Configure()
    {
        return new PostgreSqlBuilder("postgres:15.1")
            .WithResourceMapping("Chinook_PostgreSql_AutoIncrementPKs.sql", "/docker-entrypoint-initdb.d/");
    }
}

Instead of a constructor parameter, this example injects the fixture through a required property. Both styles are supported by TUnit.

1
2
3
4
5
public sealed partial class PostgreSqlContainerTest
{
    [ClassDataSource<PostgreSqlContainerFixture>(Shared = SharedType.PerClass)]
    public required PostgreSqlContainerFixture Fixture { get; init; }
}

The fixture offers the same helper methods as the test base class. CreateCommand and CreateBatch return objects that are already bound to the database and ready for execution.

1
2
3
4
5
6
7
8
[Test]
[Property(nameof(DockerCli.DockerPlatform), nameof(DockerCli.DockerPlatform.Linux))]
public async Task CreateCommandShouldExecuteAgainstDatabase(CancellationToken cancellationToken)
{
    await using var command = Fixture.CreateCommand(SelectFirstAlbumTitle);
    var title = await command.ExecuteScalarAsync(cancellationToken);
    await Assert.That(title).IsEqualTo(FirstAlbumTitle);
}

Tip

For the complete source code of this example and additional information, please refer to our test projects.