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
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,43 @@ All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.5.0] - 2026-09-08

### Changed

- **A test parameter is now pinned without being asked.** 1.4.0 pinned only what an attribute
declared, and that is the wrong default: a parameter exists to be looked at, so handing the test
one container's instance while the invocation runs against another makes the assertion
meaningless. That is as true of a plain application class a handler appends to as it is of a
`[Mock]`, and no attribute is what makes it so.

1.4.0's rule failed on the first test a scaffolded project runs, which takes a plain class and
asserts on what the handler recorded. A rule that needs an attribute to work is a rule most tests
will not get.

The rule is now two sentences. A test parameter is one instance for the whole test, unless it is
something the harness supplies to drive the application. A registration nothing holds is per
container, unless the harness pins it by name.

### Added

- `ISharedTestRegistration.IsolatedServices(MethodInfo)`, which names the parameters that must *not*
be pinned because they build containers rather than live in one - a trigger façade, a test web
application, an `HttpClient`, a generated client. Only the harness supplying them can know which
they are, and it answers per test method because the answer is a property of the signature. It
wins over every other answer, including an explicit `[Shared]`: pinning one of these is not a
preference, it turns the isolation off while the test believes it is on.

A default implementation returning empty, so it is additive.

- `IServiceProvider` is never pinned. A test asking for one is asking which container it is in,
which is the one question pinning cannot answer.

### Note

1.4.0 was released the same day and is superseded by this. Its only difference is the default above,
and nothing consumed it.

## [1.4.0] - 2026-09-08

### Added
Expand Down
4 changes: 2 additions & 2 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
local and CI builds fall back to the values below.
-->
<PropertyGroup>
<VersionPrefix>1.4.0</VersionPrefix>
<VersionPrefix>1.5.0</VersionPrefix>
<!--
Empty at 1.0.0. Set it again to cut a prerelease of the next version; the file version
carries no prerelease part, so it stays put until the version it does carry changes.
Expand All @@ -22,7 +22,7 @@
depend on. It moves at 2.0.0.
-->
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.4.0.0</FileVersion>
<FileVersion>1.5.0.0</FileVersion>
</PropertyGroup>

<!--
Expand Down
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
using System.Reflection;
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.
/// Adjusts which objects survive a test's container being rebuilt.
/// </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>.
/// The default needs no attribute, and is two sentences:
/// </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.
/// <b>A test parameter is one instance for the whole test, unless it is something the harness
/// supplies to drive the application. A registration nothing holds is per container, unless the
/// harness pins it by name.</b>
/// </para>
/// <para>
/// A parameter exists to be looked at - the test was handed it so it could assert on it, or
/// configure it and then assert on something else - so handing the test one container's instance
/// while the invocation runs against another makes the assertion meaningless. That is true of a
/// <c>[Mock]</c>, of a plain application class a handler appends to, and of a registry a test
/// registers a filter on, and no attribute is what makes it so.
/// </para>
/// <para>
/// <b>This interface is for the two exceptions.</b> <see cref="IsolatedServices"/> names the
/// parameters that must <em>not</em> be pinned because they build containers rather than live in
/// one, and <see cref="SharedServices"/> names services no parameter holds that should be kept
/// anyway. <see cref="Shared"/> answering false declines the default for one parameter.
/// </para>
/// <para>
/// An earlier version of this pinned only what an attribute declared. It failed on the first thing
/// a new user runs: a scaffolded test taking a plain application class and asserting on what the
/// handler recorded, which was rebuilt with the container the send ran on. A rule that needs an
/// attribute to work is a rule most tests will not get.
/// </para>
/// </remarks>
public interface ISharedTestRegistration {
Expand All @@ -27,18 +40,49 @@ public interface ISharedTestRegistration {
/// </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.
/// it was constructed can say so. On a parameter attribute this is only worth answering to
/// decline, since a parameter is pinned without being asked.
/// </remarks>
bool Shared => true;

/// <summary>
/// The services to pin, for an attribute that registers one without naming a parameter.
/// Services to pin that no test parameter holds.
/// </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.
/// The second exception. A parameter is pinned because the test holds it; something nothing holds
/// needs naming, which is how a harness keeps its own per-test services - a cancellation token, an
/// environment - one object across every container, and how
/// <c>[TestExport(Shared = true)]</c> keeps a fake a handler resolves and the test never sees.
/// </remarks>
IReadOnlyList<Type> SharedServices => [];

/// <summary>
/// Parameters this attribute supplies that must <em>not</em> be pinned, because they build
/// containers rather than live in one.
/// </summary>
/// <remarks>
/// <para>
/// The exception to the rule that a test parameter is one instance. A trigger façade, a test web
/// application, an <c>HttpClient</c> and a generated client are handed to a test so it can drive
/// the application, and each builds a container per call - so pinning one would pin the very
/// thing that is supposed to be rebuilt, and every call would run against one container while
/// the test believed otherwise.
/// </para>
/// <para>
/// Named by the attribute that supplies them, because nothing else can know. They are ordinary
/// types in an ordinary signature, indistinguishable from a service the application registered
/// until you know which harness put them there.
/// </para>
/// <para>
/// Takes the test method, unlike <see cref="SharedServices"/>, because the answer is a property
/// of the signature: an attribute supplies a façade for <em>this</em> test's parameters and has
/// no fixed list of its own.
/// </para>
/// <para>
/// This wins over every other answer here, including an explicit <c>[Shared]</c>. Pinning one of
/// these is not a preference that could go either way; it silently turns the isolation off.
/// </para>
/// </remarks>
/// <param name="testMethod">The test whose parameters are being supplied.</param>
IReadOnlyList<Type> IsolatedServices(MethodInfo testMethod) => [];
}
90 changes: 64 additions & 26 deletions src/DependencyModules.Testing/Impl/SharedRegistrations.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,67 +4,105 @@
namespace DependencyModules.Testing.Impl;

/// <summary>
/// Works out which services are pinned for one test.
/// Works out which services survive a test's container being rebuilt.
/// </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>
/// Types the harness itself can never pin, whatever anything else says.
/// </summary>
/// <remarks>
/// <see cref="IServiceProvider"/> is the container. A test asking for one is asking which
/// container it is in, which is the one question pinning cannot answer.
/// </remarks>
private static readonly Type[] NeverPinned = [typeof(IServiceProvider)];

/// <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.
/// The rule, in the order it is applied:
/// </para>
/// <para>
/// <b>Every parameter the test is handed is pinned.</b> A parameter exists to be looked at, so
/// handing the test one container's instance while the invocation runs against another makes the
/// assertion meaningless. That is as true of a plain application class the handler appends to as
/// it is of a <c>[Mock]</c>, which is why no attribute is required and why an earlier version of
/// this that required one failed on the first test a scaffolded project runs.
/// </para>
/// <para>
/// <b>Except the ones that drive the application.</b> A façade, a test web application, an
/// <c>HttpClient</c> and a generated client build a container per call, so pinning one pins the
/// thing meant to be rebuilt. Only the harness that supplies them knows which they are, so it
/// names them through <see cref="ISharedTestRegistration.IsolatedServices"/>, and that answer
/// wins over everything else here - it is correctness rather than preference.
/// </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.
/// <b>Plus what no parameter holds but something asked for.</b> A harness's own per-test
/// services, and <c>[TestExport(Shared = true)]</c>.
/// </para>
/// <para>
/// A type nothing registered can end up in this set - a value from a data row, a concrete class
/// the resolver constructs - and that is inert rather than wrong. Pinning rewrites descriptors,
/// and there are none to rewrite.
/// </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>();
var attributes = knownAttributes as IReadOnlyCollection<Attribute> ?? knownAttributes.ToArray();

foreach (var registration in knownAttributes.OfType<ISharedTestRegistration>()) {
if (!registration.Shared) {
continue;
}
var isolated = new HashSet<Type>(NeverPinned);

foreach (var service in registration.SharedServices) {
pinned.Add(service);
foreach (var registration in attributes.OfType<ISharedTestRegistration>()) {
foreach (var service in registration.IsolatedServices(method)) {
isolated.Add(service);
}
}

var pinned = new HashSet<Type>();

foreach (var parameter in method.GetParameters()) {
foreach (var registration in parameter.GetCustomAttributes().OfType<ISharedTestRegistration>()) {
if (!registration.Shared) {
continue;
}
var declarations = parameter.GetCustomAttributes()
.OfType<ISharedTestRegistration>()
.ToArray();

if (registration.SharedServices.Count == 0) {
pinned.Add(parameter.ParameterType);
// Declining is the only thing worth saying on a parameter, since one is pinned without
// being asked. A second attribute asking cannot undo it: two attributes on one parameter
// disagreeing is a use site asking for both, and pinned is the answer that leaves the
// test able to see what it was asserting on.
if (declarations.Any(declaration => !declaration.Shared)) {
continue;
}

continue;
}
pinned.Add(parameter.ParameterType);

foreach (var service in registration.SharedServices) {
foreach (var declaration in declarations) {
foreach (var service in declaration.SharedServices) {
pinned.Add(service);
}
}
}

foreach (var registration in attributes.OfType<ISharedTestRegistration>()) {
if (!registration.Shared) {
continue;
}

foreach (var service in registration.SharedServices) {
pinned.Add(service);
}
}

pinned.ExceptWith(isolated);

return pinned;
}
}
73 changes: 64 additions & 9 deletions src/DependencyModules.Testing/Impl/TestContainerSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,16 @@ private IServiceCollection Template(Composition composition) {
/// </para>
/// </remarks>
private static IServiceCollection BuildTemplate(Composition composition) {
var instances = Resolve(composition);

IServiceCollection template = new ServiceCollection();
var taken = new HashSet<Type>();

foreach (var descriptor in composition.Services) {
var serviceType = descriptor.ServiceType;

if (!composition.PinnedServices.Contains(serviceType) ||
descriptor.ImplementationInstance != null ||
serviceType.IsGenericTypeDefinition) {
if (!instances.TryGetValue(serviceType, out var pinned) ||
descriptor.ImplementationInstance != null) {
template.Add(descriptor);

continue;
Expand All @@ -122,18 +123,72 @@ private static IServiceCollection BuildTemplate(Composition composition) {
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));
}
foreach (var instance in pinned) {
template.Add(new ServiceDescriptor(serviceType, instance));
}
}

return template;
}

/// <summary>
/// The instances to keep, for the pinned services the first container can actually produce.
/// </summary>
/// <remarks>
/// <para>
/// <b>A service that cannot be produced is left alone rather than being an error.</b> Pinning is
/// a statement about identity, not a reason to construct something the test never asked for, and
/// a pinned set drawn from a signature holds types nothing registered - a value from a data row,
/// a concrete class the resolver builds on the spot - alongside the ones that matter.
/// </para>
/// <para>
/// The case that made this necessary is sharper than an absent registration. A harness may
/// register a <em>deliberately failing</em> factory for a parameter it cannot supply, so that
/// resolving it fails with a message naming the fix. Resolving eagerly here turned that message
/// into a failure at container build for every test that took such a parameter and never
/// resolved it - a data-driven test whose row supplies a string, for one. Leaving the descriptor
/// alone means the test either never resolves it, or resolves it and gets the error the harness
/// wrote.
/// </para>
/// <para>
/// Resolved through <c>IEnumerable&lt;T&gt;</c> 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.
/// </para>
/// <para>
/// An open generic is skipped, having no closed type to resolve.
/// </para>
/// </remarks>
private static Dictionary<Type, object[]> Resolve(Composition composition) {
var instances = new Dictionary<Type, object[]>();

foreach (var serviceType in composition.PinnedServices) {
if (serviceType.IsGenericTypeDefinition || serviceType.IsByRef || serviceType.IsPointer) {
continue;
}

object[] resolved;

try {
var sequence = typeof(IEnumerable<>).MakeGenericType(serviceType);

resolved = ((IEnumerable)composition.Pinned.GetRequiredService(sequence))
.Cast<object>()
.Where(instance => instance != null)
.ToArray();
} catch (Exception) {
continue;
}

if (resolved.Length > 0) {
instances[serviceType] = resolved;
}
}

return instances;
}

private sealed record Composition(
IServiceCollection Services,
IServiceProvider Pinned,
Expand Down
Loading
Loading