Skip to content

Let a test build containers of its own, and say what crosses between them - #59

Merged
ipjohnson merged 1 commit into
mainfrom
test-container-isolation
Sep 8, 2026
Merged

ipjohnson merged 1 commit into
mainfrom
test-container-isolation

Conversation

@ipjohnson

Copy link
Copy Markdown
Owner

The DependencyModules half of the container isolation plan. Mechanism only. Nothing here changes what any existing test does, and no policy about when to rebuild lives in this repository.

Why

A test method's invocations all share one container. That models a topology that need not exist: two queue handlers deployed as two functions are two processes, and a handler that passes only because a previous invocation warmed a singleton is a test that cannot fail for the reason production will.

What this adds

ITestContainerSource builds a container per call from a template taken once off the test's own composition. It is registered into the collection like any other service, so anything driving the application can take one through constructor injection.

Nothing happens until something asks. The template is built on the first CreateAsync and not before, so a test that never rebuilds pays for none of this. Across the suite in the consuming framework that is roughly 90% of tests.

What crosses between containers is declared

ISharedTestRegistration is how an attribute says so once, in its own definition, rather than every use site remembering [Shared]. The bar for taking it is narrow and is the whole of the reasoning:

Implement it only where isolated would be broken for that attribute rather than merely unusual.

[Mock] clears it. A substitute resolved fresh per container is one the test can assert nothing about, because the call it is asking after was recorded onto a different object. So it is shared unconditionally and no use site writes a word.

[TestExport] does not clear it. A fresh fake per container is a coherent test and often the wanted one, so it implements the interface with Shared defaulting to false and asks at the use site:

[TestExport(typeof(IOrderStore), Implementation = typeof(InMemoryOrderStore), Shared = true)]

Shared wins over Lifetime, deliberately. Keeping one object across containers is an instance registration, so setting it beside the default Transient is not a contradiction to refuse.

[Shared] is the same statement for one parameter, and parameters only. At a class or an assembly the obvious reading is "one container across the tests here", which would undo per-test isolation that already holds.

Two details that are load-bearing

The template uses AddSingleton(instance), not a factory returning the instance. Measured on MEDI 8.0.1: resolving one IDisposable from two containers gives 0 disposals for the instance overload and 2 for the factory, because a provider disposes what it created and a factory registration counts as created. The factory form would dispose a shared object once per container, the first landing while the others were still running.

Pinning resolves through IEnumerable<T>. Taking the single service would collapse a type registered more than once to its last member and leave anything injecting the sequence one element long.

Both runners

xUnit and NUnit both hand every container they built to the disposal they already ran, NUnit in reverse so a built container goes before the one holding the instances it was handed. Startup attributes run against each container, because one that skipped them is not the one the test composed. That is also why CreateAsync is asynchronous: ITestStartupAttribute is.

Tests

22 new, in three files. SharedRegistrationsTests pins the collection rule without a container or a framework. TestContainerSourceUnitTests covers the plumbing a running test cannot observe: every container reaching the runner, startup per container, a pinned disposable surviving its containers, multiple registrations kept, and nothing built until asked. TestContainerSourceTests asserts the contract end to end through real [ModuleTest] runs, including the [TestExport] default-isolated and Shared = true pair.

Full suite green in Release with ContinuousIntegrationBuild=true: 925 unit, 159 + 36 + 2 integration, both net8.0 and net10.0.

The public API diff is purely additive. No member removed, no signature changed.

🤖 Generated with Claude Code

…them

A test method's invocations all share one container today, which models a
topology that need not exist: two queue handlers deployed as two functions are
two processes, and a handler passing only because a previous invocation warmed a
singleton is a test that cannot fail for the reason production will.

ITestContainerSource builds a container per call from a template taken once off
the test's own composition. Nothing changes for a test that never asks for one -
the template is built on first use, so the cost is zero until something rebuilds.

What crosses between those containers is declared rather than inferred.
ISharedTestRegistration is how an attribute says it once in its own definition,
and the bar for taking it is that isolated would be broken rather than merely
unusual: a substitute resolved fresh per container is one the test can assert
nothing about, so [Mock] takes it unconditionally, while [TestExport] reads
perfectly well isolated and so defaults to false and asks at the use site.
[Shared] on a parameter is the same statement for one argument.

The template uses an instance registration rather than a factory returning the
instance, which is the difference between a shared object surviving the test and
being disposed once per container - a provider disposes what it created, and a
factory registration counts as created. Pinning resolves through IEnumerable<T>
so a service registered more than once keeps every registration and its order.

Both runners hand every container they built to the same disposal they already
ran, and startup runs against each one, because a container that skipped it is
not the one the test composed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ipjohnson
ipjohnson merged commit 97a132a into main Sep 8, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant