Let a test build containers of its own, and say what crosses between them - #59
Merged
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ITestContainerSourcebuilds 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
CreateAsyncand 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
ISharedTestRegistrationis 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:[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 withShareddefaulting to false and asks at the use site:Sharedwins overLifetime, deliberately. Keeping one object across containers is an instance registration, so setting it beside the defaultTransientis 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 oneIDisposablefrom 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
CreateAsyncis asynchronous:ITestStartupAttributeis.Tests
22 new, in three files.
SharedRegistrationsTestspins the collection rule without a container or a framework.TestContainerSourceUnitTestscovers 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.TestContainerSourceTestsasserts the contract end to end through real[ModuleTest]runs, including the[TestExport]default-isolated andShared = truepair.Full suite green in Release with
ContinuousIntegrationBuild=true: 925 unit, 159 + 36 + 2 integration, bothnet8.0andnet10.0.The public API diff is purely additive. No member removed, no signature changed.
🤖 Generated with Claude Code