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); + } +}