Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 48 additions & 6 deletions src/DependencyModules.NUnit/Impl/ModuleTestCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ITestContainerSource>(containerSource);

SeedEnvironment(serviceCollection, method, knownAttributes);

SetupModules(serviceCollection, method, knownAttributes);
Expand All @@ -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<IServiceProvider> { serviceProvider };

try {
foreach (var startupAttribute in knownAttributes.OfType<ITestStartupAttribute>()) {
// 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))
Expand All @@ -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]);
}
}
}

/// <summary>
/// Runs the test's startup attributes against one container.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static void Start(
ITestMethodContext context, Attribute[] knownAttributes, IServiceProvider provider) {
foreach (var startupAttribute in knownAttributes.OfType<ITestStartupAttribute>()) {
startupAttribute.StartupAsync(context, provider).GetAwaiter().GetResult();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ namespace DependencyModules.Testing.Attributes;
/// ActivatorUtilities naming the parameter's type rather than the misplaced attribute.
/// </remarks>
[AttributeUsage(AttributeTargets.Parameter)]
public class InjectValuesAttribute(params object[] value) : Attribute, IInjectValueAttribute {
public class InjectValuesAttribute(params object[] value)
: Attribute, IInjectValueAttribute, ISharedTestRegistration {

/// <summary>
/// Provides the specified values for a method parameter during dependency
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
namespace DependencyModules.Testing.Attributes.Interfaces;

/// <summary>
/// Declares that what an attribute registered is pinned for the whole test, rather than rebuilt
/// with every container the test creates.
/// </summary>
/// <remarks>
/// <para>
/// 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
/// <c>[Shared]</c>.
/// </para>
/// <para>
/// <b>The bar is narrow.</b> Implement this only where isolated would be <em>broken</em> 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. <see cref="MockAttribute"/> clears the bar because an isolated mock
/// has no coherent reading at all. <see cref="TestExportAttribute"/> does not, which is why it
/// implements this with <c>Shared</c> defaulting to false and leaves the choice at the use site.
/// </para>
/// </remarks>
public interface ISharedTestRegistration {

/// <summary>
/// Whether what this attribute registered is pinned.
/// </summary>
/// <remarks>
/// A <c>bool</c> 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.
/// </remarks>
bool Shared => true;

/// <summary>
/// The services to pin, for an attribute that registers one without naming a parameter.
/// </summary>
/// <remarks>
/// Empty means the harness already knows what to pin, which is the parameter's type for an
/// attribute sitting on one. An <see cref="ITestServiceSetupAttribute"/> 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.
/// </remarks>
IReadOnlyList<Type> SharedServices => [];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace DependencyModules.Testing.Attributes.Interfaces;

/// <summary>
/// Builds containers for one test, on demand, from what the test composed.
/// </summary>
/// <remarks>
/// <para>
/// 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
/// <see cref="SharedAttribute"/>, and the parameter is resolved once instead.
/// </para>
/// <para>
/// <b>A source rather than the <c>IServiceCollection</c>.</b> 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.
/// </para>
/// <para>
/// Asynchronous because <see cref="ITestStartupAttribute"/> 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.
/// </para>
/// </remarks>
public interface ITestContainerSource {

/// <summary>
/// A container built from the test's composition, started, and owned by the runner.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
ValueTask<IServiceProvider> CreateAsync();
}
8 changes: 7 additions & 1 deletion src/DependencyModules.Testing/Attributes/MockAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <see cref="ISharedTestRegistration">shared</see> 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.
/// </remarks>
[AttributeUsage(
AttributeTargets.Parameter,
AllowMultiple = true)]
public class MockAttribute : Attribute, ITestParameterValueProvider {
public class MockAttribute : Attribute, ITestParameterValueProvider, ISharedTestRegistration {

/// <summary>
/// Registers the double in place of the parameter's service.
Expand Down
35 changes: 35 additions & 0 deletions src/DependencyModules.Testing/Attributes/SharedAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using DependencyModules.Testing.Attributes.Interfaces;

namespace DependencyModules.Testing.Attributes;

/// <summary>
/// Keeps one test parameter across every container the test builds.
/// </summary>
/// <remarks>
/// <para>
/// One rule, covering both things a parameter can be: <b>this parameter is not rebuilt.</b>
/// </para>
/// <para>
/// On a <em>value</em> parameter - a fake, a store, anything the test asserts against - that pins the
/// instance, so every container is handed the same object.
/// </para>
/// <para>
/// On an <em>invoker</em> parameter - something that drives the application, holding an
/// <see cref="ITestContainerSource"/> 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 <em>is</em> the reuse: a response cache serving the second request, a rate limiter
/// tripping on the eleventh, a connection held open.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[AttributeUsage(AttributeTargets.Parameter)]
public class SharedAttribute : Attribute, ISharedTestRegistration { }
43 changes: 42 additions & 1 deletion src/DependencyModules.Testing/Attributes/TestExportAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/// <summary>
/// An attribute that configures and exports services to the dependency injection container
/// for test scenarios. This supports customized service registrations with specific lifetimes
Expand Down Expand Up @@ -79,6 +79,47 @@ public ServiceLifetime Lifetime {
set;
} = ServiceLifetime.Transient;

/// <summary>
/// Whether this export is kept across every container the test builds, rather than registered
/// again in each. Off unless the test asks.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// <see cref="MockAttribute"/> is over that line and this is not, so this asks:
/// </para>
/// <code>
/// [TestExport(typeof(IOrderStore), Implementation = typeof(InMemoryOrderStore), Shared = true)]
/// </code>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>This wins over <see cref="Lifetime"/>, and deliberately.</b> 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 <see cref="ServiceLifetime.Transient"/> is not a
/// contradiction to refuse - pinning one object is the only thing the pair can mean, and it is
/// what was asked for.
/// </para>
/// </remarks>
public bool Shared {
get;
set;
}

/// <summary>
/// The exported service, which is what pinning applies to when <see cref="Shared"/> is set.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
IReadOnlyList<Type> ISharedTestRegistration.SharedServices => [Service];


/// <summary>
/// Configures the service collection for a test method by adding services with specified lifetimes.
Expand Down
70 changes: 70 additions & 0 deletions src/DependencyModules.Testing/Impl/SharedRegistrations.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using System.Reflection;
using DependencyModules.Testing.Attributes.Interfaces;

namespace DependencyModules.Testing.Impl;

/// <summary>
/// Works out which services are pinned for one test.
/// </summary>
/// <remarks>
/// Shared by every integration, because the rule is the same one wherever the test runs and only the
/// discovery around it differs.
/// </remarks>
public static class SharedRegistrations {

/// <summary>
/// The service types to keep across every container the test builds.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="ISharedTestRegistration.SharedServices"/> - <c>[TestExport]</c> names the
/// service it exported. An attribute on a parameter has one by definition, so an empty
/// <c>SharedServices</c> is read as that parameter's type, which is what keeps <c>[Mock]</c> to a
/// single interface on the class and nothing else.
/// </para>
/// <para>
/// An attribute answering <c>Shared</c> 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.
/// </para>
/// </remarks>
/// <param name="method">The test method, for its parameters.</param>
/// <param name="knownAttributes">
/// The attributes in scope for the test, widest first, as the runner collected them.
/// </param>
public static IReadOnlyCollection<Type> Collect(MethodInfo method, IEnumerable<Attribute> knownAttributes) {
var pinned = new HashSet<Type>();

foreach (var registration in knownAttributes.OfType<ISharedTestRegistration>()) {
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<ISharedTestRegistration>()) {
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;
}
}
Loading
Loading