From 4b9a1ceca3bf20982c42f75397d787e042d26352 Mon Sep 17 00:00:00 2001 From: Ian Johnson Date: Tue, 8 Sep 2026 18:55:37 -0400 Subject: [PATCH] Let a test build containers of its own, and say what crosses between 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 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) --- .../Impl/ModuleTestCommand.cs | 54 ++++- .../Attributes/InjectValuesAttribute.cs | 3 +- .../Interfaces/ISharedTestRegistration.cs | 44 +++++ .../Interfaces/ITestContainerSource.cs | 36 ++++ .../Attributes/MockAttribute.cs | 8 +- .../Attributes/SharedAttribute.cs | 35 ++++ .../Attributes/TestExportAttribute.cs | 43 +++- .../Impl/SharedRegistrations.cs | 70 +++++++ .../Impl/TestContainerSource.cs | 144 ++++++++++++++ .../Impl/ModuleTestCase.cs | 38 +++- .../PublicApiTests.TestingApi.verified.txt | 31 ++- .../TestingTests/SharedRegistrationsTests.cs | 103 ++++++++++ .../TestingTests/TestContainerSourceTests.cs | 185 ++++++++++++++++++ .../TestContainerSourceUnitTests.cs | 151 ++++++++++++++ 14 files changed, 929 insertions(+), 16 deletions(-) create mode 100644 src/DependencyModules.Testing/Attributes/Interfaces/ISharedTestRegistration.cs create mode 100644 src/DependencyModules.Testing/Attributes/Interfaces/ITestContainerSource.cs create mode 100644 src/DependencyModules.Testing/Attributes/SharedAttribute.cs create mode 100644 src/DependencyModules.Testing/Impl/SharedRegistrations.cs create mode 100644 src/DependencyModules.Testing/Impl/TestContainerSource.cs create mode 100644 tests/DependencyModules.Tests/TestingTests/SharedRegistrationsTests.cs create mode 100644 tests/DependencyModules.Tests/TestingTests/TestContainerSourceTests.cs create mode 100644 tests/DependencyModules.Tests/TestingTests/TestContainerSourceUnitTests.cs diff --git a/src/DependencyModules.NUnit/Impl/ModuleTestCommand.cs b/src/DependencyModules.NUnit/Impl/ModuleTestCommand.cs index 4f93cfb..c60c9ae 100644 --- a/src/DependencyModules.NUnit/Impl/ModuleTestCommand.cs +++ b/src/DependencyModules.NUnit/Impl/ModuleTestCommand.cs @@ -41,6 +41,13 @@ public override TestResult Execute(TestExecutionContext context) { SetupTestCaseInfo(serviceCollection, testMethod, knownAttributes); + // Before the modules, so anything driving the application can take one through ordinary + // constructor injection. It answers nothing until the container below exists to take pinned + // instances from, which is why it is handed its composition rather than given it here. + var containerSource = new TestContainerSource(); + + serviceCollection.AddSingleton(containerSource); + SeedEnvironment(serviceCollection, method, knownAttributes); SetupModules(serviceCollection, method, knownAttributes); @@ -53,12 +60,26 @@ public override TestResult Execute(TestExecutionContext context) { var serviceProvider = BuildServiceProvider(moduleContext, serviceCollection, knownAttributes); + // Every container the case built, the first and any the source was asked for, disposed + // together in the finally below. + var providers = new List { serviceProvider }; + try { - foreach (var startupAttribute in knownAttributes.OfType()) { - // NUnit's command chain is synchronous — TestCommand.Execute has no async form — so - // an async hook is awaited here rather than up the stack. - startupAttribute.StartupAsync(moduleContext, serviceProvider).GetAwaiter().GetResult(); - } + Start(moduleContext, knownAttributes, serviceProvider); + + // Named throughout: three of these are delegates of shapes that would happily bind to + // one another. + containerSource.Initialize( + services: serviceCollection, + pinned: serviceProvider, + pinnedServices: SharedRegistrations.Collect(method, knownAttributes), + build: services => BuildServiceProvider(moduleContext, services, knownAttributes), + start: built => { + Start(moduleContext, knownAttributes, built); + + return ValueTask.CompletedTask; + }, + track: providers.Add); var arguments = resolver .ResolveArgumentsAsync(serviceProvider, RowArguments(testMethod)) @@ -68,7 +89,28 @@ public override TestResult Execute(TestExecutionContext context) { return innerCommand.Execute(context); } finally { - DisposeProvider(serviceProvider); + // Backwards, so a container the source built goes before the one holding the instances + // it was handed. + for (var i = providers.Count - 1; i >= 0; i--) { + DisposeProvider(providers[i]); + } + } + } + + /// + /// Runs the test's startup attributes against one container. + /// + /// + /// Every container, not only the first. A framework whose startup installs middleware or a filter + /// provider would otherwise answer through a chain that was never assembled. + /// + /// NUnit's command chain is synchronous - TestCommand.Execute has no async form - so an async + /// hook is awaited here rather than up the stack. + /// + private static void Start( + ITestMethodContext context, Attribute[] knownAttributes, IServiceProvider provider) { + foreach (var startupAttribute in knownAttributes.OfType()) { + startupAttribute.StartupAsync(context, provider).GetAwaiter().GetResult(); } } diff --git a/src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs b/src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs index c2d9f74..a6a4a2b 100644 --- a/src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs +++ b/src/DependencyModules.Testing/Attributes/InjectValuesAttribute.cs @@ -27,7 +27,8 @@ namespace DependencyModules.Testing.Attributes; /// ActivatorUtilities naming the parameter's type rather than the misplaced attribute. /// [AttributeUsage(AttributeTargets.Parameter)] -public class InjectValuesAttribute(params object[] value) : Attribute, IInjectValueAttribute { +public class InjectValuesAttribute(params object[] value) + : Attribute, IInjectValueAttribute, ISharedTestRegistration { /// /// Provides the specified values for a method parameter during dependency diff --git a/src/DependencyModules.Testing/Attributes/Interfaces/ISharedTestRegistration.cs b/src/DependencyModules.Testing/Attributes/Interfaces/ISharedTestRegistration.cs new file mode 100644 index 0000000..223186e --- /dev/null +++ b/src/DependencyModules.Testing/Attributes/Interfaces/ISharedTestRegistration.cs @@ -0,0 +1,44 @@ +namespace DependencyModules.Testing.Attributes.Interfaces; + +/// +/// Declares that what an attribute registered is pinned for the whole test, rather than rebuilt +/// with every container the test creates. +/// +/// +/// +/// A test that builds a container per invocation needs some things to survive the rebuild. A mock is +/// the clear case: a substitute resolved fresh per container is one the test can assert nothing +/// about, because the invocation recorded onto a different object. Implementing this is how an +/// attribute says so once, in its own definition, rather than every use site remembering +/// [Shared]. +/// +/// +/// The bar is narrow. Implement this only where isolated would be broken for the +/// attribute rather than merely unusual. Hiding a decision from the reader of a test is a cost; +/// hiding a non-decision is not. clears the bar because an isolated mock +/// has no coherent reading at all. does not, which is why it +/// implements this with Shared defaulting to false and leaves the choice at the use site. +/// +/// +public interface ISharedTestRegistration { + + /// + /// Whether what this attribute registered is pinned. + /// + /// + /// A bool rather than a bare marker interface, so an attribute whose answer depends on how + /// it was constructed can say so. The default suits an attribute that is always shared. + /// + bool Shared => true; + + /// + /// The services to pin, for an attribute that registers one without naming a parameter. + /// + /// + /// Empty means the harness already knows what to pin, which is the parameter's type for an + /// attribute sitting on one. An has no parameter to read, + /// so it answers here - which is also what lets an implementation outside this assembly join in + /// without the runner knowing about it. + /// + IReadOnlyList SharedServices => []; +} diff --git a/src/DependencyModules.Testing/Attributes/Interfaces/ITestContainerSource.cs b/src/DependencyModules.Testing/Attributes/Interfaces/ITestContainerSource.cs new file mode 100644 index 0000000..f2d7fcd --- /dev/null +++ b/src/DependencyModules.Testing/Attributes/Interfaces/ITestContainerSource.cs @@ -0,0 +1,36 @@ +namespace DependencyModules.Testing.Attributes.Interfaces; + +/// +/// Builds containers for one test, on demand, from what the test composed. +/// +/// +/// +/// Resolved from the test's container like any other service, so anything driving the application - +/// a trigger façade, a generated client, a host adapter - can take one and build a container per +/// call. A test that wants those calls to share one container marks that parameter +/// , and the parameter is resolved once instead. +/// +/// +/// A source rather than the IServiceCollection. A caller holding the collection could +/// mutate it, after which the third container differs from the first with nothing recording why. +/// Handing out a source also means every container a test built is tracked, so destroying them is +/// the runner's business rather than a caller's - they are disposed when the test case has run, +/// alongside the container the test itself resolved from. +/// +/// +/// Asynchronous because is, and a container that skipped startup +/// is not the one the test composed. A framework whose startup installs middleware would answer every +/// request through a chain that was never assembled. +/// +/// +public interface ITestContainerSource { + + /// + /// A container built from the test's composition, started, and owned by the runner. + /// + /// + /// Pinned services are the same objects in every container this returns. Everything else is built + /// again, keeping whatever lifetime it was registered with inside the container it belongs to. + /// + ValueTask CreateAsync(); +} diff --git a/src/DependencyModules.Testing/Attributes/MockAttribute.cs b/src/DependencyModules.Testing/Attributes/MockAttribute.cs index 083f469..c3bff27 100644 --- a/src/DependencyModules.Testing/Attributes/MockAttribute.cs +++ b/src/DependencyModules.Testing/Attributes/MockAttribute.cs @@ -25,11 +25,17 @@ namespace DependencyModules.Testing.Attributes; /// /// This carries no test framework dependency, so it is the same attribute whichever integration /// resolves the test's parameters. +/// +/// The double is shared across every container a test +/// builds, and unconditionally: 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. That is +/// the whole bar for declaring an attribute shared - not that isolating it would be unusual, but that +/// it would have no coherent reading. /// [AttributeUsage( AttributeTargets.Parameter, AllowMultiple = true)] -public class MockAttribute : Attribute, ITestParameterValueProvider { +public class MockAttribute : Attribute, ITestParameterValueProvider, ISharedTestRegistration { /// /// Registers the double in place of the parameter's service. diff --git a/src/DependencyModules.Testing/Attributes/SharedAttribute.cs b/src/DependencyModules.Testing/Attributes/SharedAttribute.cs new file mode 100644 index 0000000..cb65e59 --- /dev/null +++ b/src/DependencyModules.Testing/Attributes/SharedAttribute.cs @@ -0,0 +1,35 @@ +using DependencyModules.Testing.Attributes.Interfaces; + +namespace DependencyModules.Testing.Attributes; + +/// +/// Keeps one test parameter across every container the test builds. +/// +/// +/// +/// One rule, covering both things a parameter can be: this parameter is not rebuilt. +/// +/// +/// On a value parameter - a fake, a store, anything the test asserts against - that pins the +/// instance, so every container is handed the same object. +/// +/// +/// On an invoker parameter - something that drives the application, holding an +/// to build a container per call - it pins the container instead, +/// so every call through that parameter reaches the same one. That is the escape hatch for a test +/// whose subject is the reuse: a response cache serving the second request, a rate limiter +/// tripping on the eleventh, a connection held open. +/// +/// +/// Parameters only, deliberately. At a class or an assembly the obvious reading is "one container +/// across the tests here", which would undo the per-test isolation that already holds and that +/// nothing has ever asked to change. +/// +/// +/// On a runner or a host that builds one container anyway this is a no-op rather than an error. It +/// still says why the test is written the way it is, and the same test may be run somewhere that +/// rebuilds. +/// +/// +[AttributeUsage(AttributeTargets.Parameter)] +public class SharedAttribute : Attribute, ISharedTestRegistration { } diff --git a/src/DependencyModules.Testing/Attributes/TestExportAttribute.cs b/src/DependencyModules.Testing/Attributes/TestExportAttribute.cs index 15b429d..b745b27 100644 --- a/src/DependencyModules.Testing/Attributes/TestExportAttribute.cs +++ b/src/DependencyModules.Testing/Attributes/TestExportAttribute.cs @@ -33,7 +33,7 @@ namespace DependencyModules.Testing.Attributes; AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = true)] -public class TestExportAttribute : Attribute, ITestServiceSetupAttribute { +public class TestExportAttribute : Attribute, ITestServiceSetupAttribute, ISharedTestRegistration { /// /// An attribute that configures and exports services to the dependency injection container /// for test scenarios. This supports customized service registrations with specific lifetimes @@ -79,6 +79,47 @@ public ServiceLifetime Lifetime { set; } = ServiceLifetime.Transient; + /// + /// Whether this export is kept across every container the test builds, rather than registered + /// again in each. Off unless the test asks. + /// + /// + /// + /// Isolated by default because it reads perfectly well isolated: a fresh fake per container is a + /// coherent test, and often the one wanted. That is the line for pinning something without the + /// use site saying so - not that isolating it would be unusual, but that it would be broken. + /// is over that line and this is not, so this asks: + /// + /// + /// [TestExport(typeof(IOrderStore), Implementation = typeof(InMemoryOrderStore), Shared = true)] + /// + /// + /// Which also keeps the default strict the whole way down. An application's services are per + /// container, an export is per container, and the only things kept without anyone writing a word + /// are the ones isolation has no reading for. + /// + /// + /// This wins over , and deliberately. Keeping one object across + /// containers is an instance registration, so a shared export is one instance however it was + /// registered. Setting it beside the default is not a + /// contradiction to refuse - pinning one object is the only thing the pair can mean, and it is + /// what was asked for. + /// + /// + public bool Shared { + get; + set; + } + + /// + /// The exported service, which is what pinning applies to when is set. + /// + /// + /// Answered here rather than left to the runner because this attribute names no parameter - it + /// sits on a method, a class or an assembly - so nothing else knows what it registered. + /// + IReadOnlyList ISharedTestRegistration.SharedServices => [Service]; + /// /// Configures the service collection for a test method by adding services with specified lifetimes. diff --git a/src/DependencyModules.Testing/Impl/SharedRegistrations.cs b/src/DependencyModules.Testing/Impl/SharedRegistrations.cs new file mode 100644 index 0000000..90601e0 --- /dev/null +++ b/src/DependencyModules.Testing/Impl/SharedRegistrations.cs @@ -0,0 +1,70 @@ +using System.Reflection; +using DependencyModules.Testing.Attributes.Interfaces; + +namespace DependencyModules.Testing.Impl; + +/// +/// Works out which services are pinned for one test. +/// +/// +/// Shared by every integration, because the rule is the same one wherever the test runs and only the +/// discovery around it differs. +/// +public static class SharedRegistrations { + + /// + /// The service types to keep across every container the test builds. + /// + /// + /// + /// Two sources, and the difference is only in how each names what it registered. An attribute on + /// the method, the class or the assembly registers a service without naming a parameter, so it + /// answers - [TestExport] names the + /// service it exported. An attribute on a parameter has one by definition, so an empty + /// SharedServices is read as that parameter's type, which is what keeps [Mock] to a + /// single interface on the class and nothing else. + /// + /// + /// An attribute answering Shared false contributes nothing rather than un-pinning what + /// something else pinned. Two attributes naming one service disagreeing is a use site asking for + /// both, and pinning is the answer that leaves the test able to see what it asked to see. + /// + /// + /// The test method, for its parameters. + /// + /// The attributes in scope for the test, widest first, as the runner collected them. + /// + public static IReadOnlyCollection Collect(MethodInfo method, IEnumerable knownAttributes) { + var pinned = new HashSet(); + + foreach (var registration in knownAttributes.OfType()) { + if (!registration.Shared) { + continue; + } + + foreach (var service in registration.SharedServices) { + pinned.Add(service); + } + } + + foreach (var parameter in method.GetParameters()) { + foreach (var registration in parameter.GetCustomAttributes().OfType()) { + if (!registration.Shared) { + continue; + } + + if (registration.SharedServices.Count == 0) { + pinned.Add(parameter.ParameterType); + + continue; + } + + foreach (var service in registration.SharedServices) { + pinned.Add(service); + } + } + } + + return pinned; + } +} diff --git a/src/DependencyModules.Testing/Impl/TestContainerSource.cs b/src/DependencyModules.Testing/Impl/TestContainerSource.cs new file mode 100644 index 0000000..005d021 --- /dev/null +++ b/src/DependencyModules.Testing/Impl/TestContainerSource.cs @@ -0,0 +1,144 @@ +using System.Collections; +using DependencyModules.Testing.Attributes.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace DependencyModules.Testing.Impl; + +/// +/// The runner's : builds a container per call from a template +/// taken once off the test's own composition. +/// +/// +/// +/// Registered into the collection before the test's container is built, because anything that wants +/// one resolves it like any other service, and filled in afterwards - the source cannot describe how +/// to rebuild a container until the first one exists to take the pinned instances from. Registered as +/// an instance, so it belongs to the test case rather than to any container and is never disposed by +/// one. +/// +/// +/// Nothing happens until something asks. The template is built on the first +/// and not before, so a test that never rebuilds - which is nearly all of +/// them - pays for none of this. +/// +/// +public sealed class TestContainerSource : ITestContainerSource { + private readonly object _gate = new(); + + private Composition? _composition; + private IServiceCollection? _template; + + /// + /// What the runner knows and this does not, handed over once the test's container exists. + /// + /// The collection the test's container was built from. + /// The container to take pinned instances out of. + /// The service types to keep, from . + /// Builds a provider the same way the runner built the first one. + /// Runs the test's startup attributes against a newly built provider. + /// Hands a built provider to the runner, which disposes it when the case has run. + public void Initialize( + IServiceCollection services, + IServiceProvider pinned, + IReadOnlyCollection pinnedServices, + Func build, + Func start, + Action track) { + _composition = new Composition(services, pinned, pinnedServices, build, start, track); + } + + /// + public async ValueTask CreateAsync() { + var composition = _composition + ?? throw new InvalidOperationException( + $"This {nameof(TestContainerSource)} was never initialized, so there is " + + "nothing to build a container from. The runner does that once the test's " + + "own container exists."); + + var provider = composition.Build(Template(composition)); + + composition.Track(provider); + + await composition.Start(provider); + + return provider; + } + + /// + /// Under a lock because a test is free to drive two calls at once, and building the template + /// twice would take a second set of pinned instances out of the first container - leaving two + /// objects where the whole point is one. + /// + private IServiceCollection Template(Composition composition) { + if (_template != null) { + return _template; + } + + lock (_gate) { + return _template ??= BuildTemplate(composition); + } + } + + /// + /// The test's collection with every pinned service replaced by the instance the first container + /// produced. + /// + /// + /// + /// 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; an instance + /// it was handed does not. The first disposal would otherwise land while other containers were + /// still running. + /// + /// + /// Resolved through IEnumerable<T> rather than as a single service, so a type + /// registered more than once keeps every registration and its order. Taking the single service + /// would collapse the set to its last member and leave anything injecting the sequence one + /// element long. + /// + /// + /// A descriptor that already carries an instance is left alone: it is the same object in every + /// container built from this collection already, which is how the module environment is shared + /// without anyone asking. An open generic is left alone too, having no closed type to resolve. + /// + /// + private static IServiceCollection BuildTemplate(Composition composition) { + IServiceCollection template = new ServiceCollection(); + var taken = new HashSet(); + + foreach (var descriptor in composition.Services) { + var serviceType = descriptor.ServiceType; + + if (!composition.PinnedServices.Contains(serviceType) || + descriptor.ImplementationInstance != null || + serviceType.IsGenericTypeDefinition) { + template.Add(descriptor); + + continue; + } + + if (!taken.Add(serviceType)) { + continue; + } + + var sequence = typeof(IEnumerable<>).MakeGenericType(serviceType); + + foreach (var instance in (IEnumerable)composition.Pinned.GetRequiredService(sequence)) { + if (instance != null) { + template.Add(new ServiceDescriptor(serviceType, instance)); + } + } + } + + return template; + } + + private sealed record Composition( + IServiceCollection Services, + IServiceProvider Pinned, + IReadOnlyCollection PinnedServices, + Func Build, + Func Start, + Action Track); +} diff --git a/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs b/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs index 188750e..9319605 100644 --- a/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs +++ b/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs @@ -99,6 +99,13 @@ private async Task SetupServiceCollection() { SetupTestCaseInfo(serviceCollection, knownAttributes); + // Before the modules, so anything driving the application can take one through ordinary + // constructor injection. It answers nothing until the container below exists to take pinned + // instances from, which is why it is handed its composition rather than given it here. + var containerSource = new TestContainerSource(); + + serviceCollection.AddSingleton(containerSource); + SeedEnvironment(serviceCollection, knownAttributes); SetupModules(serviceCollection, knownAttributes); @@ -118,11 +125,34 @@ private async Task SetupServiceCollection() { // run; see the remarks on the class. _providers.Add(provider); + await StartAsync(context, knownAttributes, provider); + + // Named throughout, for the reason the base constructor above gives: three of these are + // delegates of shapes that would happily bind to one another. + containerSource.Initialize( + services: serviceCollection, + pinned: provider, + pinnedServices: SharedRegistrations.Collect(TestMethod.Method, knownAttributes), + build: services => BuildServiceProvider(context, services, knownAttributes), + start: built => StartAsync(context, knownAttributes, built), + track: _providers.Add); + + return new StartupValues(provider, resolver); + } + + /// + /// Runs the test's startup attributes against one container. + /// + /// + /// Every container, not only the first. A framework whose startup installs middleware or a filter + /// provider would otherwise answer through a chain that was never assembled, which is a container + /// that looks composed and is not. + /// + private static async ValueTask StartAsync( + ITestMethodContext context, Attribute[] knownAttributes, IServiceProvider provider) { foreach (var startupAttribute in knownAttributes.OfType()) { await startupAttribute.StartupAsync(context, provider); } - - return new StartupValues(provider, resolver); } private void SetupTestCaseInfo(ServiceCollection serviceCollection, Attribute[] knownAttributes) { @@ -143,7 +173,7 @@ private void SetupTestCaseInfo(ServiceCollection serviceCollection, Attribute[] /// which is the reverse of how every other attribute here resolves. /// private IServiceProvider BuildServiceProvider( - ITestMethodContext context, ServiceCollection serviceCollection, Attribute[] knownAttributes) { + ITestMethodContext context, IServiceCollection serviceCollection, Attribute[] knownAttributes) { var serviceProviderBuilderAttribute = knownAttributes.OfType().LastOrDefault(); @@ -169,7 +199,7 @@ private IServiceProvider BuildServiceProvider( /// mocked, which is what [Mock] is for. /// private void SetupServiceSetupAttributes( - ITestMethodContext context, ServiceCollection serviceCollection, Attribute[] knownAttributes) { + ITestMethodContext context, IServiceCollection serviceCollection, Attribute[] knownAttributes) { var setupAttributes = knownAttributes .OfType() .OrderBy(attribute => attribute is IMockSupportAttribute ? 0 : 1); diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt index 86aae95..357883d 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.TestingApi.verified.txt @@ -1,25 +1,31 @@ namespace DependencyModules.Testing.Attributes { [System.AttributeUsage(System.AttributeTargets.Parameter)] - public class InjectValuesAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.IInjectValueAttribute + public class InjectValuesAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.IInjectValueAttribute, DependencyModules.Testing.Attributes.Interfaces.ISharedTestRegistration { public InjectValuesAttribute(params object[] value) { } public object[] ProvideValue(System.IServiceProvider serviceProvider, System.Reflection.ParameterInfo parameter) { } } [System.AttributeUsage(System.AttributeTargets.Parameter, AllowMultiple=true)] - public class MockAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.ITestParameterValueProvider + public class MockAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.ISharedTestRegistration, DependencyModules.Testing.Attributes.Interfaces.ITestParameterValueProvider { public MockAttribute() { } public System.Threading.Tasks.Task GetParameterValueAsync(DependencyModules.Testing.Attributes.Interfaces.ITestMethodContext testMethod, System.IServiceProvider serviceProvider, System.Reflection.ParameterInfo parameter) { } public void SetupServiceCollection(DependencyModules.Testing.Attributes.Interfaces.ITestMethodContext testMethod, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection, System.Reflection.ParameterInfo parameter) { } } + [System.AttributeUsage(System.AttributeTargets.Parameter)] + public class SharedAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.ISharedTestRegistration + { + public SharedAttribute() { } + } [System.AttributeUsage(System.AttributeTargets.Assembly | System.AttributeTargets.Class | System.AttributeTargets.Method, AllowMultiple=true)] - public class TestExportAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.ITestServiceSetupAttribute + public class TestExportAttribute : System.Attribute, DependencyModules.Testing.Attributes.Interfaces.ISharedTestRegistration, DependencyModules.Testing.Attributes.Interfaces.ITestServiceSetupAttribute { public TestExportAttribute(System.Type service) { } public System.Type? Implementation { get; set; } public Microsoft.Extensions.DependencyInjection.ServiceLifetime Lifetime { get; set; } public System.Type Service { get; } + public bool Shared { get; set; } public void SetupServiceCollection(DependencyModules.Testing.Attributes.Interfaces.ITestMethodContext testMethod, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection) { } } } @@ -46,6 +52,15 @@ namespace DependencyModules.Testing.Attributes.Interfaces { System.IServiceProvider BuildServiceProvider(DependencyModules.Testing.Attributes.Interfaces.ITestMethodContext testMethod, Microsoft.Extensions.DependencyInjection.IServiceCollection serviceCollection); } + public interface ISharedTestRegistration + { + bool Shared { get; } + System.Collections.Generic.IReadOnlyList SharedServices { get; } + } + public interface ITestContainerSource + { + System.Threading.Tasks.ValueTask CreateAsync(); + } public interface ITestMethodContext { System.Collections.Generic.IReadOnlyList Attributes { get; } @@ -78,6 +93,16 @@ namespace DependencyModules.Testing.Impl public static System.Collections.Generic.IEnumerable GetTestAttributes(this System.Reflection.ParameterInfo parameterInfo) where T : class { } } + public static class SharedRegistrations + { + public static System.Collections.Generic.IReadOnlyCollection Collect(System.Reflection.MethodInfo method, System.Collections.Generic.IEnumerable knownAttributes) { } + } + public sealed class TestContainerSource : DependencyModules.Testing.Attributes.Interfaces.ITestContainerSource + { + public TestContainerSource() { } + public System.Threading.Tasks.ValueTask CreateAsync() { } + public void Initialize(Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.IServiceProvider pinned, System.Collections.Generic.IReadOnlyCollection pinnedServices, System.Func build, System.Func start, System.Action track) { } + } public sealed class TestParameterResolver { public TestParameterResolver(DependencyModules.Testing.Attributes.Interfaces.ITestMethodContext testMethod) { } diff --git a/tests/DependencyModules.Tests/TestingTests/SharedRegistrationsTests.cs b/tests/DependencyModules.Tests/TestingTests/SharedRegistrationsTests.cs new file mode 100644 index 0000000..84d6cf8 --- /dev/null +++ b/tests/DependencyModules.Tests/TestingTests/SharedRegistrationsTests.cs @@ -0,0 +1,103 @@ +using System.Reflection; +using DependencyModules.Testing.Attributes; +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.Testing.Impl; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace DependencyModules.Tests.TestingTests; + +/// +/// Which services a test pins, decided without a container or a test framework in the way. +/// +public class SharedRegistrationsTests { + + private interface IThing; + + private interface IOther; + + private class Thing : IThing; + + /// An attribute that answers no, to prove declining is not the same as not implementing. + private class NotSharedAttribute : Attribute, ISharedTestRegistration { + public bool Shared => false; + + public IReadOnlyList SharedServices => [typeof(IOther)]; + } + + private static IReadOnlyCollection Collect(string method, params Attribute[] known) => + SharedRegistrations.Collect( + typeof(SharedRegistrationsTests).GetMethod(method, BindingFlags.NonPublic | BindingFlags.Static)!, + known); + + private static void Plain(IThing thing) { } + + private static void Marked([Shared] IThing thing) { } + + private static void Mocked([Mock] IThing thing) { } + + private static void Declined([NotShared] IThing thing) { } + + private static void Several([Shared] IThing thing, [Mock] IOther other, string plain) { } + + /// A parameter says nothing on its own, which is what keeps the default isolated. + [Fact] + public void AnUnmarkedParameterIsNotPinned() { + Assert.Empty(Collect(nameof(Plain))); + } + + [Fact] + public void SharedPinsTheParametersType() { + Assert.Equal([typeof(IThing)], Collect(nameof(Marked))); + } + + /// + /// The point of the interface: [Mock] pins without the use site writing [Shared]. + /// + [Fact] + public void MockPinsWithoutBeingAsked() { + Assert.Equal([typeof(IThing)], Collect(nameof(Mocked))); + } + + [Fact] + public void AnAttributeAnsweringNoPinsNothing() { + Assert.Empty(Collect(nameof(Declined))); + } + + [Fact] + public void EveryMarkedParameterIsCollected() { + Assert.Equal([typeof(IThing), typeof(IOther)], Collect(nameof(Several))); + } + + /// + /// An attribute with no parameter names what it registered, which is how [TestExport] joins in + /// from the method, the class or the assembly. + /// + [Fact] + public void AnExportAskingToBeSharedNamesItsOwnService() { + var shared = new TestExportAttribute(typeof(IThing)) { + Implementation = typeof(Thing), Shared = true + }; + + Assert.Equal([typeof(IThing)], Collect(nameof(Plain), shared)); + } + + /// The default, and the reason the default is what it is. + [Fact] + public void AnExportIsNotPinnedUnlessItAsks() { + var isolated = new TestExportAttribute(typeof(IThing)) { Implementation = typeof(Thing) }; + + Assert.Empty(Collect(nameof(Plain), isolated)); + } + + /// + /// Two attributes naming one service and disagreeing is a use site asking for both. Pinning is + /// the answer that leaves the test able to see what it asked to see. + /// + [Fact] + public void OneAttributeAskingIsEnough() { + var isolated = new TestExportAttribute(typeof(IThing)) { Implementation = typeof(Thing) }; + + Assert.Equal([typeof(IThing)], Collect(nameof(Marked), isolated)); + } +} diff --git a/tests/DependencyModules.Tests/TestingTests/TestContainerSourceTests.cs b/tests/DependencyModules.Tests/TestingTests/TestContainerSourceTests.cs new file mode 100644 index 0000000..1818a18 --- /dev/null +++ b/tests/DependencyModules.Tests/TestingTests/TestContainerSourceTests.cs @@ -0,0 +1,185 @@ +using DependencyModules.NSubstitute; +using DependencyModules.Runtime.Attributes; +using DependencyModules.Testing.Attributes; +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.xUnit.Attributes; +using Microsoft.Extensions.DependencyInjection; +using NSubstitute; +using Xunit; + +namespace DependencyModules.Tests.TestingTests; + +/// +/// A test that builds containers of its own, and what crosses between them. +/// +/// +/// The whole of the contract in one file: everything is rebuilt, pinned services are not, and both +/// halves of that are asserted against real containers rather than against the collector's answer. +/// +[DependencyModule] +public partial class ContainerSourceModule { } + +public interface ICounter { + int Value { get; } + + void Bump(); +} + +[SingletonService] +public class Counter : ICounter { + public int Value { get; private set; } + + public void Bump() => Value++; +} + +public interface IAudit { + void Record(string what); +} + +[NSubstituteSupport] +public class TestContainerSourceTests { + + /// + /// A singleton is a singleton inside one container and nothing more, which is the property the + /// whole design turns on. + /// + [ModuleTest(typeof(ContainerSourceModule))] + public async Task EachContainerGetsItsOwnApplicationSingleton(ITestContainerSource source) { + var first = await source.CreateAsync(); + var second = await source.CreateAsync(); + + first.GetRequiredService().Bump(); + first.GetRequiredService().Bump(); + second.GetRequiredService().Bump(); + + Assert.Equal(2, first.GetRequiredService().Value); + Assert.Equal(1, second.GetRequiredService().Value); + Assert.NotSame(first.GetRequiredService(), second.GetRequiredService()); + } + + /// + /// The counterpart, and the reason [Mock] declares itself shared: the substitute the test holds + /// is the one every container was built against, so what a container did is visible here. + /// + [ModuleTest(typeof(ContainerSourceModule))] + public async Task AMockIsTheSameObjectInEveryContainer(ITestContainerSource source, [Mock] IAudit audit) { + var first = await source.CreateAsync(); + var second = await source.CreateAsync(); + + Assert.Same(audit, first.GetRequiredService()); + Assert.Same(audit, second.GetRequiredService()); + + first.GetRequiredService().Record("one"); + second.GetRequiredService().Record("two"); + + Received.InOrder(() => { + audit.Record("one"); + audit.Record("two"); + }); + } + + /// + /// The container the test itself resolves from is one of the set, not something beside it. + /// + [ModuleTest(typeof(ContainerSourceModule))] + public async Task TheTestsOwnContainerHoldsTheSameMock( + ITestContainerSource source, IServiceProvider own, [Mock] IAudit audit) { + var built = await source.CreateAsync(); + + Assert.Same(audit, own.GetRequiredService()); + Assert.Same(audit, built.GetRequiredService()); + Assert.NotSame(own, built); + } + + /// + /// Asking twice gives two containers. Nothing caches, because a caller asking again is a caller + /// that wants a cold one. + /// + [ModuleTest(typeof(ContainerSourceModule))] + public async Task EveryCallBuildsAContainer(ITestContainerSource source) { + var first = await source.CreateAsync(); + var second = await source.CreateAsync(); + + Assert.NotSame(first, second); + } + + /// + /// [Shared] does for anything what [Mock] does for a substitute, which is the escape hatch for a + /// value the test holds and asserts on. + /// + [ModuleTest(typeof(ContainerSourceModule))] + public async Task SharedPinsAPlainParameter(ITestContainerSource source, [Shared] ICounter counter) { + var first = await source.CreateAsync(); + var second = await source.CreateAsync(); + + first.GetRequiredService().Bump(); + second.GetRequiredService().Bump(); + + Assert.Same(counter, first.GetRequiredService()); + Assert.Same(counter, second.GetRequiredService()); + Assert.Equal(2, counter.Value); + } +} + +/// +/// An export is per container until the use site says otherwise. +/// +/// +/// The pair below is the whole argument for the default. Both read perfectly well, which is why +/// neither is chosen for the test: isolating an export is coherent rather than broken, so it does not +/// clear the bar for sharing without a word at the use site. +/// +[NSubstituteSupport] +[TestExport(typeof(ICounter), Implementation = typeof(Counter), Lifetime = ServiceLifetime.Singleton)] +public class IsolatedTestExportTests { + + [ModuleTest] + public async Task AnExportIsRebuiltWithEachContainer(ITestContainerSource source) { + var first = await source.CreateAsync(); + var second = await source.CreateAsync(); + + first.GetRequiredService().Bump(); + + Assert.NotSame(first.GetRequiredService(), second.GetRequiredService()); + Assert.Equal(1, first.GetRequiredService().Value); + Assert.Equal(0, second.GetRequiredService().Value); + } +} + +[NSubstituteSupport] +[TestExport(typeof(ICounter), Implementation = typeof(Counter), Lifetime = ServiceLifetime.Singleton, + Shared = true)] +public class SharedTestExportTests { + + [ModuleTest] + public async Task AnExportAskingToBeSharedCrossesEveryContainer(ITestContainerSource source) { + var first = await source.CreateAsync(); + var second = await source.CreateAsync(); + + first.GetRequiredService().Bump(); + second.GetRequiredService().Bump(); + + Assert.Same(first.GetRequiredService(), second.GetRequiredService()); + Assert.Equal(2, first.GetRequiredService().Value); + } + + /// + /// Shared wins over Lifetime, because keeping one object is an instance registration whatever the + /// registration said. + /// + [ModuleTest] + [TestExport(typeof(IAudit), Implementation = typeof(RecordingAudit), + Lifetime = ServiceLifetime.Transient, Shared = true)] + public async Task SharedOverridesATransientLifetime(ITestContainerSource source) { + var built = await source.CreateAsync(); + + Assert.Same(built.GetRequiredService(), built.GetRequiredService()); + } +} + +public class RecordingAudit : IAudit { + public List Records { get; } = []; + + public void Record(string what) => Records.Add(what); +} + diff --git a/tests/DependencyModules.Tests/TestingTests/TestContainerSourceUnitTests.cs b/tests/DependencyModules.Tests/TestingTests/TestContainerSourceUnitTests.cs new file mode 100644 index 0000000..30f1f32 --- /dev/null +++ b/tests/DependencyModules.Tests/TestingTests/TestContainerSourceUnitTests.cs @@ -0,0 +1,151 @@ +using DependencyModules.Testing.Attributes.Interfaces; +using DependencyModules.Testing.Impl; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace DependencyModules.Tests.TestingTests; + +/// +/// The source driven directly, for the parts a test running through a framework cannot observe. +/// +public class TestContainerSourceUnitTests { + + private interface IThing; + + private class Thing : IThing; + + private sealed class Tracked : IDisposable { + public int Disposals { get; private set; } + + public void Dispose() => Disposals++; + } + + private sealed class Harness { + public List Tracked { get; } = []; + + public List Started { get; } = []; + + public TestContainerSource Source { get; } = new(); + + public IServiceProvider Pin { get; } + + public Harness(Action compose, params Type[] pinned) { + var services = new ServiceCollection(); + + services.AddSingleton(Source); + + compose(services); + + Pin = services.BuildServiceProvider(); + + Source.Initialize( + services, + Pin, + pinned, + collection => collection.BuildServiceProvider(), + provider => { + Started.Add(provider); + + return ValueTask.CompletedTask; + }, + Tracked.Add); + } + } + + /// + /// Every container goes to the runner, which is what makes destroying them the runner's business + /// rather than the caller's. + /// + [Fact] + public async Task EveryContainerBuiltIsHandedToTheRunner() { + var harness = new Harness(services => services.AddSingleton()); + + var first = await harness.Source.CreateAsync(); + var second = await harness.Source.CreateAsync(); + + Assert.Equal([first, second], harness.Tracked); + } + + /// + /// Startup runs against each one. A container that skipped it is not the one the test composed. + /// + [Fact] + public async Task StartupRunsForEveryContainer() { + var harness = new Harness(services => services.AddSingleton()); + + var first = await harness.Source.CreateAsync(); + var second = await harness.Source.CreateAsync(); + + Assert.Equal([first, second], harness.Started); + } + + /// + /// A pinned service survives its container being disposed, which is the whole reason the template + /// uses an instance registration rather than a factory returning the same object. + /// + [Fact] + public async Task APinnedDisposableIsNotDisposedByTheContainersItIsHandedTo() { + var tracked = new Tracked(); + + var harness = new Harness( + services => services.AddSingleton(_ => tracked), + typeof(Tracked)); + + var first = await harness.Source.CreateAsync(); + var second = await harness.Source.CreateAsync(); + + Assert.Same(tracked, first.GetRequiredService()); + Assert.Same(tracked, second.GetRequiredService()); + + ((IDisposable)first).Dispose(); + ((IDisposable)second).Dispose(); + + Assert.Equal(0, tracked.Disposals); + } + + /// + /// A type registered more than once keeps every registration, because collapsing the set to its + /// last member would leave anything injecting the sequence one element long. + /// + [Fact] + public async Task PinningKeepsEveryRegistrationOfAService() { + var harness = new Harness( + services => { + services.AddSingleton(); + services.AddSingleton(); + }, + typeof(IThing)); + + var built = await harness.Source.CreateAsync(); + + Assert.Equal(2, built.GetServices().Count()); + Assert.Equal( + harness.Pin.GetServices(), + built.GetServices()); + } + + /// + /// Nothing is built until something asks, so a test that never rebuilds pays for none of this. + /// + [Fact] + public void NothingIsBuiltUntilAsked() { + var harness = new Harness(services => services.AddSingleton()); + + Assert.Empty(harness.Tracked); + Assert.Empty(harness.Started); + } + + /// + /// A source nobody initialized says so, rather than answering with a container built from + /// nothing. + /// + [Fact] + public async Task AnUninitializedSourceRefuses() { + var source = new TestContainerSource(); + + var refused = await Assert.ThrowsAsync( + async () => await source.CreateAsync()); + + Assert.Contains("never initialized", refused.Message); + } +}