Run temporary containers in .NET tests with Meziantou.Framework.TemporaryContainers

 
 
  • Gérald Barré

Integration tests are much more valuable when they run against real services. Instead of mocking a database or cache, you can start a temporary container, run your test, and dispose everything at the end.

The Meziantou.Framework.TemporaryContainers package provides a lightweight API to manage disposable containers from .NET. Its main difference with the existing libraries is that it is not tied to Docker. Most of them need an endpoint that speaks the Docker Engine API. That covers Docker and the runtimes that ship a Docker compatible socket, but it leaves out runtimes that expose only their own CLI, such as Apple's container on macOS or wslc on Windows.

Meziantou.Framework.TemporaryContainers supports both: it talks to the Docker Engine API when a daemon is there, and drives the runtime CLI when it is not. The test code is the same in every case.

In a previous post about test strategy, I recommended testing with real dependencies whenever possible: Automated tests.

#Why use temporary containers?

  • Better confidence: You validate the real behavior of the dependency, not a mocked approximation.
  • Better isolation: Each test creates its own disposable environment.
  • Better portability: The same test can run on developer machines and CI.
  • Less test maintenance: You avoid writing and keeping complex mocks in sync.

#Install the package

PowerShell
dotnet add package Meziantou.Framework.TemporaryContainers

#Start a container in a test

The following example starts a Redis container, waits until the port is ready, then retrieves the mapped host port.

C#
using Meziantou.Framework.TemporaryContainers;

var definition = new ContainerDefinition(ImageSource.FromRegistry("redis:8"));
definition.Environment.Add("ALLOW_EMPTY_PASSWORD", "yes");
definition.Ports.Add(new ContainerPort(6379));
definition.WaitStrategies.Add(Wait.ForPort(6379));

await using var container = definition.CreateContainer();
await container.StartAsync();

var hostPort = container.GetMappedPort(6379);
Console.WriteLine($"Redis is available at 127.0.0.1:{hostPort}");

StartAsync creates the container, starts it, and executes the configured wait strategies before returning.

Because the container is created with await using, it is automatically removed when disposed. This is a great default for isolated tests.

Note that this sample contains nothing about Docker. The container runtime is resolved at run time, so the exact same test runs on a Linux CI agent with Docker, on a macOS laptop with Apple's container, and on a Windows machine with WSL containers.

#One test, five container runtimes

The runtime is a property of the definition and defaults to ContainerRuntime.Auto:

RuntimeHow it is drivenNotes
ContainerRuntime.DockerApiDocker Engine API over the Unix socket, the Windows named pipe, or TCPNo process is started. Also covers Podman's Docker compatible socket, Colima, Rancher Desktop, and remote daemons
ContainerRuntime.Dockerdocker CLIUseful when the socket is not reachable but the CLI is configured, for example with a context or a credential helper
ContainerRuntime.Podmanpodman CLIWorks with a rootless, daemonless Podman that does not expose a Docker socket
ContainerRuntime.AppleContainerApple's container CLImacOS only. No Docker compatible API at all
ContainerRuntime.Wslcwslc CLIWindows, containers in WSL

##How auto-detection works

ContainerRuntime.Auto probes the candidates in order and keeps the first one that answers. The order depends on the operating system, so the runtime that is native to the machine wins over a compatibility layer:

  • Windows: Wslc, DockerApi, Docker, Podman
  • macOS: AppleContainer, DockerApi, Docker, Podman
  • Linux: DockerApi, Docker, Podman

A runtime is not considered available just because its executable is in the PATH. The probe runs a cheap command (docker version, container ls -q) or opens a connection, so a CLI whose daemon is not running is skipped. A success is cached for the lifetime of the process, a failure is not, so a daemon started after the first test is still detected.

The DockerApi candidate reads DOCKER_HOST and supports unix://, npipe://, tcp://, http://, and https://. When the variable is not set, it tries /var/run/docker.sock, the rootless socket derived from XDG_RUNTIME_DIR on Linux, and the docker_engine named pipe on Windows.

##Force a specific runtime

Auto-detection is convenient locally, but a CI job usually knows exactly what it provides. Setting the runtime explicitly skips the probes and turns a missing runtime into a clear error instead of a fallback to something else:

C#
var definition = ContainerDefinition.CreateRedis();
definition.Runtime = ContainerRuntime.Podman;

await using var container = definition.CreateContainer();
await container.StartAsync();

You can also query a runtime before using it, which is handy to skip tests instead of failing them on a machine without any container runtime:

C#
[Fact]
public async Task Redis_integration_test()
{
    Assert.SkipUnless(await ContainerRuntime.Auto.IsSupportedAsync(TestContext.Current.CancellationToken), "No container runtime is available");

    await using var container = ContainerDefinition.CreateRedis().CreateContainer();
    await container.StartAsync();
}

##What the library normalizes

Supporting several runtimes is only useful if the differences do not leak into the tests. A few examples of what the library handles:

  • Apple's container cannot assign a random host port. The library picks a free port on the host before creating the container, so GetMappedPort returns something meaningful on every runtime.
  • Port mapping is read from the runtime's own inspect output, whose shape differs between Docker, Apple container, and the Engine API.
  • wslc has no restart command, so RestartAsync falls back to a stop followed by a start.
  • Log timestamps are only reported by the runtimes that emit them. LogEntry.Timestamp is null on the others rather than being invented.

##Differences that remain visible

Some limits cannot be hidden, and it is better to know them before choosing a runtime:

  • wslc has no volume commands.
  • Apple's container has no volume driver, its mount descriptors cannot contain a comma, and, as of version 1.1.0, it hangs on a container that mounts a volume a deleted container used. Keep a volume attached to a single container there.
  • PauseAsync throws a NotSupportedException on Apple's container.

#Reuse a container across runs

When test startup time matters, you can reuse containers across runs:

C#
using Meziantou.Framework.TemporaryContainers;

var definition = ContainerDefinition.CreatePostgreSql();
definition.ReuseId = "integration-tests-postgres";

await using var container = definition.CreateContainer();
await container.StartAsync();

When ReuseId is set, an existing matching container can be reused, and reused containers are not removed when disposed.

#Use built-in database helpers

The package includes pre-configured helpers for several common services:

  • CreateRedis
  • CreatePostgreSql
  • CreateMongoDb
  • CreateSqlServer

They expose GetConnectionString() so you can plug them directly into your test setup.

C#
using Meziantou.Framework.TemporaryContainers;

await using var redis = ContainerDefinition.CreateRedis().CreateContainer();
await redis.StartAsync();
var redisConnectionString = redis.GetConnectionString();

#Interact with running containers

For diagnostics or advanced scenarios, you can execute commands, read files, and stream logs:

C#
using Meziantou.Framework.TemporaryContainers;

await using var container = definition.CreateContainer();
await container.StartAsync();

// Execute a command in the container
var result = await container.ExecAsync(exec =>
{
    exec.Command = ["echo", "hello"];
    exec.WorkingDirectory = "/tmp";
});

// Get logs from the container
await foreach (var log in container.GetLogsAsync())
{
    Console.WriteLine(log.Message);
}

// Read a file from the container
await using var stream = await container.OpenReadAsync("/tmp/myfile.txt");

// Write a file to the container
await container.WriteFileAsync("/tmp/myfile.txt", new MemoryStream([1, 2, 3]));

#Export container logs to ILogger or xUnit output

When a test fails, container logs are often the fastest way to understand what happened. You can forward those logs to any ILogger provider (console, Serilog, OpenTelemetry, etc.) using definition.Logging.Logger, and optionally to xUnit output.

C#
public sealed class RedisTests(ITestOutputHelper output)
{
    [Fact]
    public async Task Forward_logs_to_xunit_output()
    {
        var definition = ContainerDefinition.CreateRedis();
        // Use Meziantou.Extensions.Logging.Xunit.v3
        definition.Logging.Logger = new XunitLogger(output); // Forward logs to xUnit output

        await using var container = definition.CreateContainer();
        await container.StartAsync();
    }
}

If you use xUnit, you can connect ILogger to ITestOutputHelper with a dedicated provider. I wrote a full guide here: How to write logs from ILogger to xUnit.net ITestOutputHelper.

#Summary

Meziantou.Framework.TemporaryContainers is a practical way to improve integration tests with real dependencies while keeping tests deterministic and isolated. The API is small and includes useful database shortcuts, but the part that matters most in practice is the runtime support: because the library drives the Docker Engine API and the docker, podman, container, and wslc CLIs, the same test suite runs on a Windows laptop with WSL, on a macOS machine with Apple's container, and on a Linux CI agent with Docker or rootless Podman, without any test code change and without depending on a Docker compatible socket.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?