Tamp.AdjacentContainer 0.1.1

Prefix Reserved
There is a newer version of this package available.
See the version list below for details.
dotnet add package Tamp.AdjacentContainer --version 0.1.1
                    
NuGet\Install-Package Tamp.AdjacentContainer -Version 0.1.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Tamp.AdjacentContainer" Version="0.1.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Tamp.AdjacentContainer" Version="0.1.1" />
                    
Directory.Packages.props
<PackageReference Include="Tamp.AdjacentContainer" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Tamp.AdjacentContainer --version 0.1.1
                    
#r "nuget: Tamp.AdjacentContainer, 0.1.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Tamp.AdjacentContainer@0.1.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Tamp.AdjacentContainer&version=0.1.1
                    
Install as a Cake Addin
#tool nuget:?package=Tamp.AdjacentContainer&version=0.1.1
                    
Install as a Cake Tool

Tamp.AdjacentContainer

Fixture-side dual-mode container acquisition for Tamp adopters. Tests reach an adjacent Postgres / Azurite / Service Bus emulator via a standardized env-var contract on CI, and spawn a local Testcontainers instance on dev workstations — same connection-string shape, automatic disposal of locally-spawned containers only.

Package Status
Tamp.AdjacentContainer 0.1.0 (initial)

Why this exists

You have integration tests that need a real Postgres / Azurite / Service Bus to run against. Locally on your dev machine, Testcontainers spins one up — fine. Then your CI agent runs Docker-in-Docker without a mounted host socket, the agent can't spawn sibling containers, and your test class fails at fixture init with DockerUnavailableException — or worse, hangs for 90 seconds before timing out.

The escape valve every adopter eventually reaches: provision a sidecar Postgres on the agent host, export a connection string via env var, refactor the test fixtures to consume that env var when present and fall back to Testcontainers when absent. The pattern is identical across every project that hits this wall.

Tamp.AdjacentContainer is that pattern, expressed once in the framework so every adopter doesn't get the disposal-ownership semantics subtly wrong.

This package pairs with — but does not depend on — two siblings:

  • Tamp.Testcontainers.V4 — a synchronous Docker reachability probe (Testcontainers.Probe()) that fails fast with an actionable diagnostic before testcontainers-dotnet hangs. Use as a .OnlyWhen(...) gate on a Target IntegrationTests.
  • Tamp.AdjacentContainer.Provisioning (landing in v0.2.0) — the CI-agent side. Generates compose, brings up the sidecar stack, exports the env vars this package reads, tears down in the build target's Finally.

Install

dotnet add package Tamp.AdjacentContainer

Multi-targets net8/9/10.

The acquisition contract

Every resource follows the same dual-mode flow:

  1. Probe the resource-specific env var (default keys below; override with .WithEnvironmentOverride(...)).
  2. If the env var is set → return a TampConnection with Mode = Adjacent. Disposal is a no-op (you don't own the resource).
  3. If absent and local fallback is enabled (default) → spawn the resource via Testcontainers, return a TampConnection with Mode = LocalSpawned. Disposal stops and removes the container.
  4. If neither path works → throw TampAdjacentContainerUnavailableException with an actionable diagnostic.

Examples

Postgres

using Tamp.AdjacentContainer;

await using var pg = await TampAdjacentContainer
    .ForPostgres()
    .WithDatabase("strata_test")
    .AcquireAsync();

// pg.ConnectionString — Npgsql-compatible regardless of mode
// pg.Mode — Adjacent (env var supplied) or LocalSpawned (Testcontainers)

Default env var: TAMP_PG_CONNECTION. Override with .WithEnvironmentOverride("STRATA_TEST_PG_CONNECTION") if your pipeline already uses a different name.

Default local-fallback image: postgres:16-alpine. Override with .WithLocalFallback("postgres:15-alpine") for older schema-version testing.

Azurite (Azure Storage emulator)

await using var az = await TampAdjacentContainer
    .ForAzurite()
    .AcquireAsync();

var blob = new BlobServiceClient(az.ConnectionString);

Default env var: TAMP_AZURITE_CONNECTION. Local-fallback image: mcr.microsoft.com/azure-storage/azurite:latest.

Service Bus emulator

await using var sb = await TampAdjacentContainer
    .ForServiceBusEmulator()
    .AcquireAsync();

var client = new ServiceBusClient(sb.ConnectionString);

Default env var: TAMP_SBUS_CONNECTION. Local-fallback image: mcr.microsoft.com/azure-messaging/servicebus-emulator:latest. The builder accepts Microsoft's EULA by default — use .WithEulaAcceptance(false) to opt out (the spawn then fails per Microsoft's contract).

The env-var contract

Resource Default env var Connection-string shape
Postgres TAMP_PG_CONNECTION Npgsql (Host=...;Database=...;Username=...;Password=...)
Azurite TAMP_AZURITE_CONNECTION Azure Storage (DefaultEndpointsProtocol=...;AccountName=...;...)
Service Bus emulator TAMP_SBUS_CONNECTION Service Bus (Endpoint=sb://...;SharedAccessKeyName=...;...)

Adopters pin these conventions in their CI pipeline step — the test process inherits the env vars, fixtures pick them up automatically, no per-test wiring needed.

How configuration resolves

When AcquireAsync() runs, the builder resolves the connection in this order:

  1. Explicit env-var override — if .WithEnvironmentOverride("CUSTOM_KEY") was chained AND CUSTOM_KEY is set non-empty, its value is the connection string. Mode = Adjacent.
  2. Default env varTAMP_PG_CONNECTION / TAMP_AZURITE_CONNECTION / TAMP_SBUS_CONNECTION per resource. If set non-empty, its value is the connection string. Mode = Adjacent.
  3. Local fallback — unless .DisableLocalFallback() was chained, the resource is spawned via Testcontainers. Mode = LocalSpawned.
  4. FailureTampAdjacentContainerUnavailableException is thrown with Resource and EnvVarKey populated, and a message naming the env-var key and remediation.

Resource-specific builder options (WithDatabase, WithUsername, WithPassword, WithLocalFallback(image: ...)) apply ONLY to the local-fallback spawn. In adjacent mode they are ignored — the env-var-supplied connection string is authoritative end-to-end. If your env var encodes Database=strata_dev but you chained .WithDatabase("strata_test"), your tests hit strata_dev silently. Pin the database via the env-var value, not the builder, when running on a sidecar.

Schema state in adjacent mode

Tamp.AdjacentContainer does NOT reset state between acquisitions. In LocalSpawned mode you get a fresh container per acquisition by construction, but in Adjacent mode the sidecar Postgres / Azurite / Service Bus survives across test runs and carries whatever schema and data the previous run left behind.

This is fixture-side responsibility, not the framework's. Three common patterns, picked per your tradeoffs:

Pattern Speed Caveat
DROP SCHEMA public CASCADE; CREATE SCHEMA public; per test Slow (full schema recreate per test) Always correct; the safe default
Per-test unique schema name (e.g. test_${Guid.NewGuid():N}) Fast Fixtures must scope all DDL/DML to the schema; reference-data seeders must too
BEGIN; ... ROLLBACK; per test Fastest DDL (CREATE TABLE, etc.) doesn't roll back in Postgres; works only for DML-only tests

Pick once per test class and stay consistent. v1.0+ may surface a pg.ResetAsync() hook to standardize pattern 1, but no v0.x commitment.

xUnit fixture-lifecycle interaction

Practical addendum to "Schema state in adjacent mode" for xUnit consumers. The other test frameworks have the same issue with different attribute names ; see the bottom of this section.

xUnit creates a fresh test-class instance per test method. The instinctive pattern is to implement IAsyncLifetime directly on the test class and put AcquireAsync() + destructive setup (DROP DATABASE ... WITH (FORCE), schema reset, seed reload) in InitializeAsync. That works fine in LocalSpawned mode because each test gets its own freshly-spawned container ; per-test resets are part of the lifecycle by construction.

In Adjacent mode the resource is shared across all test-class instances. Per-test destructive setup terminates connections the prior test instance still has in flight. The next test fails with:

Npgsql.NpgsqlException: Exception while reading from stream
---- System.IO.IOException: Unable to read data from the transport connection: An established connection was aborted by the software in your host machine.

It looks like a transport / pool issue, not a fixture-pattern issue. The actionable signal is buried.

Wrong (per-test IAsyncLifetime with destructive setup):

public class AuditTests : IAsyncLifetime
{
    private TampConnection? _pg;

    public async Task InitializeAsync()
    {
        _pg = await TampAdjacentContainer.ForPostgres().AcquireAsync();
        // DROP DATABASE WITH (FORCE) per-test kills the prior instance's pooled connections.
        await CreateFreshDbAsync(_pg.ConnectionString, "audit_test");
    }
    // ...
}

Right (class-scoped IClassFixture<T>):

public class AuditFixture : IAsyncLifetime
{
    public TampConnection? Pg { get; private set; }
    public string ConnectionString { get; private set; } = "";

    public async Task InitializeAsync()
    {
        Pg = await TampAdjacentContainer.ForPostgres().AcquireAsync();
        ConnectionString = await CreateFreshDbAsync(Pg.ConnectionString, "audit_test");
    }

    public async Task DisposeAsync()
    {
        if (Pg is not null) await Pg.DisposeAsync();
    }
}

public class AuditTests : IClassFixture<AuditFixture>
{
    private readonly AuditFixture _fixture;
    public AuditTests(AuditFixture fixture) { _fixture = fixture; }
    // ...
}

The destructive setup runs once per test class, not once per test, and never races a still-in-flight prior instance.

For tests inside a collection (cross-class state), use ICollectionFixture<T> with the same shape ; the fixture is then shared across every test class in the collection.

Same shape, other frameworks:

  • NUnit ; put the destructive setup in [OneTimeSetUp] (class-scoped), not [SetUp] (per-test).
  • MSTest ; put the destructive setup in [ClassInitialize], not [TestInitialize].

Per-test setup is the wrong scope for any destructive operation against an adjacent resource, regardless of test framework.

CI-only enforcement: disable the local-fallback

On a CI agent, "Docker daemon unreachable → spawn fails → cascade of timeouts" is a worse failure mode than "env var missing → fail fast with a clear remediation message." Disable the fallback on CI:

await using var pg = await TampAdjacentContainer
    .ForPostgres()
    .DisableLocalFallback()  // Adjacent mode only — no Docker spawn attempts
    .AcquireAsync();

When the env var is absent in this mode, the builder throws immediately with the env-var key, resource name, and remediation text in the exception message.

Disposal semantics

TampConnection is IAsyncDisposable. Tests pair it with await using so resources tear down at scope exit:

  • Adjacent modeDisposeAsync is a no-op. You never tear down resources you didn't provision.
  • LocalSpawned modeDisposeAsync stops and removes the Testcontainers instance.

DisposeAsync is idempotent. Multiple calls are safe.

Errors

Exception When Caller action
TampAdjacentContainerUnavailableException env var absent AND (fallback disabled OR Docker unreachable) Read the message — it contains the env-var key and resource name. Wire the env var in your pipeline, or make Docker reachable, or fix the fallback toggle.
ArgumentException empty / null env-var key passed to WithEnvironmentOverride(...) Pass a real key.

What this package is not

  • It is not a Postgres / Azurite / Service Bus client. Bring your own (Npgsql, Azure.Storage.Blobs, Azure.Messaging.ServiceBus) and pass the connection string in.
  • It does not run schema migrations or seed data. That's project-side. The connection is ready to use; what you put in it is your job.
  • It does not handle test isolation (per-test transactions, snapshot/restore, per-class containers). That's xUnit / NUnit / collection-fixture territory.
  • It does not provision the sidecar Postgres on your CI agent. That's Tamp.AdjacentContainer.Provisioning (v0.2.0). Today you write a compose step in your pipeline that exports the env var; this package does the consumption half.

Companion: Tamp.Testcontainers.V4

When the local-fallback path is enabled and Docker is unreachable, the spawn fails with whatever message testcontainers-dotnet produced — usually a 30-second timeout. Add Tamp.Testcontainers.V4's probe as a Target gate to fail at build-target boundaries instead of fixture-init boundaries:

Target IntegrationTests => _ => _
    .OnlyWhen(() => Testcontainers.Probe().IsAvailable,
              "Docker unreachable — skipping integration tests on this agent.")
    .DependsOn(Compile)
    .Executes(() => DotNet.Test(...));

The probe and Tamp.AdjacentContainer are independent — use either, both, or neither depending on your CI shape.

Releasing

Releases follow the Tamp dogfood pattern: bump <Version> in Directory.Build.props, tag v<X.Y.Z>, GitHub Actions runs dotnet tamp Ci then dotnet tamp Push.

License

MIT. See LICENSE.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on Tamp.AdjacentContainer:

Package Downloads
Tamp.AdjacentContainer.Local

Local-fallback half of Tamp.AdjacentContainer's dual-mode acquisition. Provides the concrete Postgres / Azurite / Service Bus emulator builders (spawned via Testcontainers when the adjacent env var is missing) plus the TampAdjacentContainer.ForXxx() facade. Pulls Testcontainers as a hard runtime dep — add this package ONLY in projects that actually need the local-spawn path (typically test projects). Build-side projects that only read connection-string env vars should reference Tamp.AdjacentContainer alone and use TampHostConnection.FromEnvironment.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.0 111 5/16/2026
0.1.1 99 5/13/2026
0.1.0 87 5/12/2026