Skip to content

About

Testcontainers-style library for .NET (net10.0): run throwaway Linux containers in integration tests on Microsoft WSL Containers (WSLC) with no Docker install, or on Docker. Add one package, write your test code once: zero configuration between Windows dev (WSLC) and Linux CI (Docker). Seven service modules.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

Purview.Containers

NuGet version Release

A Testcontainers-style library for .NET that runs throwaway Linux containers for integration testing — on Microsoft WSL Containers (WSLC) with no Docker installation, or on Docker through Testcontainers.

Two backends, one API — one reference. Containers are created through the backend-neutral Purview.Containers.Core abstractions, so the same test suite runs on WSL Containers (Purview.Containers.Wsl) or Docker (Purview.Containers.Docker, driven by Testcontainers). Add the umbrella package Purview.Containers to a plain net10.0 project — no Windows target framework needed — and it brings the abstractions plus both backends, gets WSLC on a Windows developer machine and Docker on a Linux CI runner without changing a line of test code or configuration, and can be pinned with PURVIEW_CONTAINERS_BACKEND. The Purview.Containers.Wsl package is multi-target: its net10.0 facade loads the WSLC implementation at run time on Windows and reports wsl as unavailable everywhere else.

Using it in your tests (auto) — the copy-paste zero-config shape.

Backends: WSLC or Docker — comparison, side-by-side project setup, CI example and troubleshooting.

The WSLC backend uses Microsoft.WSL.Containers for ordinary lifecycle operations and a bounded wslc CLI fallback for entrypoint/user/labels, managed networks, reuse, resource copy and image builds. It requires no Docker installation. The Docker backend is built on Testcontainers for .NET.

Both backends expose file copy/read, one-shot exit waits, managed bridge networks/aliases and Dockerfile builds through the same contract. Startup has a five-minute total budget; exec defaults to one minute, stop to 15 seconds and cleanup to 30 seconds, all configurable. Cancellation is propagated; synchronous native WSLC calls cannot be forcibly interrupted. See Capabilities and Cancellation for APIs, ownership and limits.

Experimental. This project is an experiment in running throwaway containers through Microsoft WSL Containers. The public API, defaults and packaging rules can change between prereleases, and there is no production support guarantee. Pin the exact package version you build against, and read Consumer Requirements before adopting it.

Status: preview. Backend-neutral abstractions, two backends (WSL Containers and Docker), wait strategies, the Image/Tag parser, registry auth, observability, hardening, and nine service modules (PostgreSQL, Redis, SQL Server, RabbitMQ, Azurite, NATS, MySQL, Azure Key Vault Emulator, Azure Service Bus Emulator). On WSLC, images are shared by default (StorageMode.Shared): sessions reuse a stable image store (%LOCALAPPDATA%\Purview\WslContainers\images) so images are pulled once, not per session; StorageMode.PerSession provides isolation. Move the store with PURVIEW_CONTAINERS_STORAGE_PATH or WslContainerRuntimeOptions.StoragePath. Runnable samples live in samples/getting-started.

Prerequisites

Standalone bind sharing and Aspire

Purview.Containers.Environment resolves bind paths for Docker-compatible CLI consumers without referencing the umbrella package, Testcontainers or WSLC. It verifies native paths, running WSL translations and explicit shared-remote mappings against the daemon using two-way file markers. Purview.Containers.Aspire adds opt-in builder.AddContainerEnvironment() integration for any Aspire AppHost. Both target net10.0; the adapter requires Aspire 13.6.4 or later. These are separate from the existing Docker backend's automatic WSL2 path handling. Docker Engine inside a WSL distribution is not Microsoft WSL Containers. See the environment package guide and Aspire adapter guide for setup, permissions and remote-host limits.

Container backends

Pick a backend — see Backends: WSLC or Docker for the comparison — or skip the choice: reference the umbrella Purview.Containers and it brings the abstractions plus both backends, selecting WSLC on Windows and Docker elsewhere automatically. Pick a single backend below only when you want to fix the runtime.

WSL Containers backend

  • Windows 11 (22H2 or later) or Windows Server 2025 with WSL Containers, installed via wsl --install --no-distribution (verified against WSL 3.0.1.0).
  • A consuming project that is .NET 10 or later. A platform-neutral net10.0 project binds the portable facade and gets automatic WSLC-or-Docker selection; a Windows target framework (net10.0-windows10.0.19041.0, x64 or arm64) binds the implementation directly. The Purview.Containers.Wsl package ships MSBuild defaults for the supporting settings.

Docker backend

  • Any reachable Docker daemon, and a net10.0 (or later) project on any platform.
  • Windows bind mounts are translated automatically for a verified local Docker Engine inside WSL2; no consumer path setting is needed. See WSL2 bind mounts.

.NET SDK 11 is required to build this repository (it pins 11.0.100-rc.1); SDK 10 is enough for a Docker consumer.

Verify:

wsl --version     # WSL Containers installed (verified against 3.0.1.0)
wslc version      # e.g. 3.0.1.0
docker info       # a Docker daemon the tests can reach

The library reports missing prerequisites via WslContainerRuntime.GetInfoAsync() (WSLC) or DockerContainerBackend.GetInfoAsync() (Docker); it never installs a runtime on its own.

The full consumer contract, the exact errors raised when it is not met, and the EnableWindowsTargeting workaround for non-Windows CI agents are documented in Consumer Requirements.

Target developer experience

The generic container API below is implemented and working — and the identical code runs on either backend (see Backends: WSLC or Docker):

await using var container = new ContainerBuilder()
    .WithImage("docker.io/library/redis:latest")
    .WithPortBinding(6379, assignRandomHostPort: true)
    .WithWaitStrategy(Wait.ForTcpPort(6379))
    .Build();

await container.StartAsync();
int port = container.GetMappedPublicPort(6379);

PostgreSQL:

await using var postgres = new PostgreSqlBuilder()
    .WithDatabase("integration")
    .WithUsername("postgres")
    .WithPassword("postgres")
    .Build();

await postgres.StartAsync();

await using var connection = new NpgsqlConnection(postgres.GetConnectionString());
await connection.OpenAsync();

SQL Server / Redis / RabbitMQ:

await using var sqlServer = new MsSqlBuilder().WithPassword("SomeStrong!Password1").AcceptLicense().Build();
await sqlServer.StartAsync();
string sqlConn = sqlServer.GetConnectionString();

await using var redis = new RedisBuilder().Build();
await redis.StartAsync();
string redisConn = redis.GetConnectionString();

await using var rabbitMq = new RabbitMqBuilder().WithUsername("guest").WithPassword("guest").Build();
await rabbitMq.StartAsync();
string amqp = rabbitMq.GetConnectionString();

Key design decisions

Decision Evidence
One shared process-wide session, shared storage path the image store is keyed by storage path; session start is ~20 ms; concurrent sessions cannot share the VHD, so the runtime falls back to an isolated store
Unique session names wslc-{pid}-{rand} session names are machine-reserved
Default NetworkingMode = Bridged port mappings require Bridged; the default is none
Random host ports via native windowsPort=0 race-free random assignment
Serialize session/container lifecycle ops a concurrent Start can race (0x8000FFFF)
No Ryuk-style reaper needed Session.Dispose() frees the session; orphans block only their own name
No container-name DNS containers reach each other by IP only
Image/WithTag fail fast invalid images/tags rejected at configuration time, never at pull time

Repository layout

src/
  Containers.slnx    canonical solution (restore, build, test, pack)
  src/
    Core/         backend-neutral abstractions (Purview.Containers.Core)
    Wsl/          WSL Containers backend (Purview.Containers.Wsl)
    Docker/       Docker backend (Purview.Containers.Docker)
    Environment/  standalone verified CLI bind sharing (no backend dependencies)
    Aspire/       optional Aspire adapter over Environment
    Containers/   umbrella package (Purview.Containers): Core + both backends
    PostgreSql/   Redis/    MsSql/    MySql/
    RabbitMq/     Azurite/  Nats/     AzureKeyVaultEmulator/
    AzureServiceBusEmulator/                                  service modules
  tests/          TUnit unit + integration projects (WSLC and Docker suites)
samples/
  getting-started/    runnable samples (WslSample, DockerSample, AutoSample)
docs/
  wiki/               project wiki (mkdocs.yml -> docs_dir: docs/wiki)
    index.md  Home.md  _Sidebar.md  Getting-Started.md  Using-in-Your-Tests.md
    Backends.md  Consumer-Requirements.md  Architecture.md  Lifecycle.md
    Networking.md  Wait-Strategies.md  Modules.md  Testing.md  Packaging.md
    Release-Flow.md  Contributing.md  Contributing-Modules.md

Documentation

The project documentation lives in docs/wiki and is published as a MkDocs site (mkdocs.yml, docs_dir: docs/wiki, aggregated by the purview-dev website):

Every package also ships its own README.md (from src/src/<Project>/Sdk/README.md), so dotnet add package Purview.Containers.<Module> brings documentation specific to that package.

All nine service modules work today:

await using var postgres = new PostgreSqlBuilder()
    .WithDatabase("tests")
    .WithUsername("postgres")
    .WithPassword("postgres")
    .Build();

await postgres.StartAsync();   // waits for pg_isready

await using var connection = new NpgsqlConnection(postgres.GetConnectionString());
await connection.OpenAsync();
await using var redis = new RedisBuilder().Build();

await redis.StartAsync();      // waits for redis-cli ping

using var redisConnection = await ConnectionMultiplexer.ConnectAsync(redis.GetConnectionString());
await redisConnection.GetDatabase().ExecuteAsync("PING");
await using var sqlServer = new MsSqlBuilder()
    .WithPassword("SomeStrong!Password1")
    .AcceptLicense()           // explicit EULA acceptance required
    .Build();

await sqlServer.StartAsync();  // waits for a real SQL Server connection

await using var sqlConnection = new SqlConnection(sqlServer.GetConnectionString());
await sqlConnection.OpenAsync();
await using var rabbitMq = new RabbitMqBuilder()
    .WithUsername("guest")
    .WithPassword("guest")
    .Build();

await rabbitMq.StartAsync();   // waits for 'Server startup complete'

var factory = new ConnectionFactory { Uri = rabbitMq.GetAmqpEndpoint() };
using var amqp = await factory.CreateConnectionAsync();
await using var azurite = new AzuriteBuilder().Build();

await azurite.StartAsync();    // waits for the blob/queue/table listeners

// Carries the well-known devstoreaccount1 key, so the Azure Storage clients authenticate as-is.
string connectionString = azurite.GetConnectionString();
Uri blob = azurite.GetBlobEndpoint();
await using var keyVault = new AzureKeyVaultEmulatorBuilder().Build();

await keyVault.StartAsync();   // waits for the HTTPS listener

// Clients are already wired for the emulator (pinned certificate, emulated credential).
var secrets = keyVault.GetSecretClient();
await secrets.SetSecretAsync("mySecret", "myValue");
await using var serviceBus = new AzureServiceBusEmulatorBuilder()
    .AcceptLicense()           // explicit: the emulator and SQL Server EULAs
    .Build();

await serviceBus.StartAsync(); // starts its SQL Server sidecar, then waits for GET /health

await using var client = new ServiceBusClient(serviceBus.GetConnectionString());
await client.CreateSender("queue.1").SendMessageAsync(new ServiceBusMessage("hello"));

Running the tests

Every test process owns a single shared WSLC session and, by default, uses the shared image store (%LOCALAPPDATA%\Purview\WslContainers\images). A WSLC session exclusively locks its storage.vhdx, and the lock is taken lazily on the first store access — so running test assemblies in parallel makes the losers fail with 0x80070020:

The process cannot access the file because it is being used by another process.

The runtime verifies the store on first use and transparently falls back to an isolated per-process store (removed when that process's session terminates), but running test modules serially keeps the warm shared image cache (no per-process re-pull) and is the fastest option. Test projects run one at a time (.config/purview-build.json sets Build:MaxTestParallelism to 1); inside a project the container suites also cap their own parallelism so they do not stampede Testcontainers' resource reaper or the WSLC session VM.

just test                     # dotnet test, one test module at a time, retrying failed tests twice

# Visual Studio: Test > Options > untick "Run Tests in Parallel" (or set
# "Maximum Parallel Test Projects" to 1) before running the WSLC integration tests.

Failures are retried at the test-project level by Microsoft.Testing.Platform's retry extension: a retry restarts the test host, so WSLC gets a fresh session. There is no per-assembly retry attribute. In CI, the Docker suites run on every pull request, the WSLC contract suite runs on a hosted windows-2025 runner, and the full WSLC service-module matrix runs nightly. Set PURVIEW_CONTAINERS_REQUIRE_BACKEND=wsl|docker to make a missing runtime fail instead of skip.

To watch a container start end to end, run a sample — the same code, on either backend:

just sample-wsl               # needs WSL Containers
just sample-docker            # needs a Docker daemon

The Wsl.IntegrationTests module starts many real containers in one session and can take several minutes, because WSLC serialises container operations; expect slow-test warnings while it runs.

Verifying the consumer contract

just verify-consumers          # pack, then build 23 throwaway consumer projects against ./artifacts
just verify-consumers -Keep    # same, keeping the generated projects for inspection

just verify-consumers asserts every claim in Consumer Requirements: the happy path, the shipped buildTransitive defaults, the PCC0001/PCC0002 guards, and the non-.NET-11 escape hatch that deliberately does not work. It needs network access and is therefore a local step rather than part of the [Category=Unit] pipeline filter.

License

MIT. This project is not affiliated with, or endorsed by, the Testcontainers project or Microsoft.

About

Testcontainers-style library for .NET (net10.0): run throwaway Linux containers in integration tests on Microsoft WSL Containers (WSLC) with no Docker install, or on Docker. Add one package, write your test code once: zero configuration between Windows dev (WSLC) and Linux CI (Docker). Seven service modules.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages