From 20656f829c1a8f1cf91329c64a03456de14c7b24 Mon Sep 17 00:00:00 2001 From: Ian Johnson Date: Tue, 1 Sep 2026 08:53:55 -0400 Subject: [PATCH] Seed the test's environment before its modules apply Module registrations are conditioned as they are applied: LoadModules answers [IfEnvironment] from the IModuleEnvironment already in the collection, or a process default when there is none. Both test runners loaded modules before the service-setup pass, so an environment a setup attribute registered arrived after every condition had been decided against the default - a test had no way to put itself under the environment it declares. Hardened's second framework trial hit exactly this: [EnvironmentName] on a test method against an [IfEnvironment] module, and the gated registration never applied. IModuleEnvironmentProvider is the new hook: an attribute names the environment, both runners consult it before loading any module, and the narrowest scope that answers decides. It lives in Runtime.Interfaces beside IDependencyModuleProvider and takes a MethodInfo rather than a test-method context, because it returns a Runtime type and Testing deliberately does not reference Runtime. Co-Authored-By: Claude Fable 5 --- .../EnvironmentSeedingTests.cs | 46 ++++++++++ .../EnvironmentSeedingTests.cs | 91 +++++++++++++++++++ .../Impl/ModuleTestCommand.cs | 21 +++++ .../Interfaces/IModuleEnvironmentProvider.cs | 38 ++++++++ .../Impl/ModuleTestCase.cs | 26 ++++++ .../PublicApiTests.RuntimeApi.verified.txt | 4 + 6 files changed, 226 insertions(+) create mode 100644 integ-tests/SutProject.NUnitTests/EnvironmentSeedingTests.cs create mode 100644 integ-tests/SutProject.Tests/EnvironmentTests/EnvironmentSeedingTests.cs create mode 100644 src/DependencyModules.Runtime/Interfaces/IModuleEnvironmentProvider.cs diff --git a/integ-tests/SutProject.NUnitTests/EnvironmentSeedingTests.cs b/integ-tests/SutProject.NUnitTests/EnvironmentSeedingTests.cs new file mode 100644 index 0000000..80a8cb3 --- /dev/null +++ b/integ-tests/SutProject.NUnitTests/EnvironmentSeedingTests.cs @@ -0,0 +1,46 @@ +using System.Reflection; +using DependencyModules.NUnit.Attributes; +using DependencyModules.Runtime; +using DependencyModules.Runtime.Attributes; +using DependencyModules.Runtime.Interfaces; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; + +namespace SutProject.NUnitTests; + +/// +/// The NUnit twin of the xUnit environment-seeding tests: the seeded environment reaches module +/// conditions through the real runner, which applies modules before the service-setup pass. +/// +[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)] +public class SeededEnvironmentAttribute(string name) : Attribute, IModuleEnvironmentProvider { + public IModuleEnvironment? ProvideEnvironment(MethodInfo testMethod) => + new ModuleEnvironment(false, name); +} + +public interface IGatedByEnvironment { } + +[SingletonService(Realm = typeof(SeededEnvironmentModule))] +[IfEnvironment("seeded-environment")] +public class GatedByEnvironment : IGatedByEnvironment { } + +[DependencyModule(OnlyRealm = true)] +public partial class SeededEnvironmentModule { } + +public class EnvironmentSeedingTests { + + [ModuleTest(typeof(SeededEnvironmentModule))] + [SeededEnvironment("seeded-environment")] + public void AGatedRegistrationAppliesUnderTheSeededEnvironment(IServiceProvider provider) { + Assert.That(provider.GetService(), Is.Not.Null); + } + + /// + /// The gate has to hold in the other direction, or the test above passes because the + /// condition was never compiled in. + /// + [ModuleTest(typeof(SeededEnvironmentModule))] + public void TheSameRegistrationIsAbsentWithoutASeed(IServiceProvider provider) { + Assert.That(provider.GetService(), Is.Null); + } +} diff --git a/integ-tests/SutProject.Tests/EnvironmentTests/EnvironmentSeedingTests.cs b/integ-tests/SutProject.Tests/EnvironmentTests/EnvironmentSeedingTests.cs new file mode 100644 index 0000000..4f77114 --- /dev/null +++ b/integ-tests/SutProject.Tests/EnvironmentTests/EnvironmentSeedingTests.cs @@ -0,0 +1,91 @@ +using System.Reflection; +using DependencyModules.Runtime; +using DependencyModules.Runtime.Attributes; +using DependencyModules.Runtime.Interfaces; +using DependencyModules.xUnit.Attributes; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace SutProject.Tests.EnvironmentTests; + +/// +/// What an integration's environment attribute looks like: name the environment, hand it over. +/// Pinned away from process variables so a machine's ASPNETCORE_ENVIRONMENT cannot reach these +/// tests. +/// +[AttributeUsage(AttributeTargets.Assembly | AttributeTargets.Class | AttributeTargets.Method)] +public class SeededEnvironmentAttribute(string name) : Attribute, IModuleEnvironmentProvider { + public IModuleEnvironment? ProvideEnvironment(MethodInfo testMethod) => + new ModuleEnvironment(false, name); +} + +public interface IGatedByEnvironment { } + +[SingletonService(Realm = typeof(SeededEnvironmentModule))] +[IfEnvironment("seeded-environment")] +public class GatedByEnvironment : IGatedByEnvironment { } + +[DependencyModule(OnlyRealm = true)] +public partial class SeededEnvironmentModule { } + +/// +/// The seeded environment reaches module conditions through the real test runner. +/// +/// +/// proves the same behaviour for a hand-built +/// collection through AddModules. These run through [ModuleTest] itself, which is +/// the path that had no way to supply an environment at all: modules are applied before the +/// service-setup pass, so every condition had been decided against the process default before an +/// attribute could register anything. +/// +public class EnvironmentSeedingTests { + + [ModuleTest] + [SeededEnvironmentModule] + [SeededEnvironment("seeded-environment")] + public void AGatedRegistrationAppliesUnderTheSeededEnvironment(IServiceProvider provider) { + Assert.NotNull(provider.GetService()); + } + + /// + /// The gate has to hold in the other direction, or the test above passes because the + /// condition was never compiled in. + /// + [ModuleTest] + [SeededEnvironmentModule] + public void TheSameRegistrationIsAbsentWithoutASeed(IServiceProvider provider) { + Assert.Null(provider.GetService()); + } + + /// The environment a module reads is the seeded instance, not a parallel default. + [ModuleTest] + [EnvironmentAwareModule] + [SeededEnvironment("seeded-environment")] + public void AModuleReadingTheEnvironmentSeesTheSeededOne(IEnvironmentDependency dependency) { + Assert.Equal("seeded-environment", dependency.EnvironmentName); + } + + [ModuleTest] + [EnvironmentAwareModule] + public void WithoutASeedTheProcessDefaultApplies(IEnvironmentDependency dependency) { + Assert.Equal(ModuleEnvironment.CreateDefault().EnvironmentName, dependency.EnvironmentName); + } +} + +/// Narrowest scope wins, matching how every other attribute here resolves. +[SeededEnvironment("outer-environment")] +public class EnvironmentSeedingPrecedenceTests { + + [ModuleTest] + [EnvironmentAwareModule] + public void AClassLevelSeedApplies(IEnvironmentDependency dependency) { + Assert.Equal("outer-environment", dependency.EnvironmentName); + } + + [ModuleTest] + [EnvironmentAwareModule] + [SeededEnvironment("inner-environment")] + public void TheMethodsSeedBeatsTheClasses(IEnvironmentDependency dependency) { + Assert.Equal("inner-environment", dependency.EnvironmentName); + } +} diff --git a/src/DependencyModules.NUnit/Impl/ModuleTestCommand.cs b/src/DependencyModules.NUnit/Impl/ModuleTestCommand.cs index bd48dbe..4f93cfb 100644 --- a/src/DependencyModules.NUnit/Impl/ModuleTestCommand.cs +++ b/src/DependencyModules.NUnit/Impl/ModuleTestCommand.cs @@ -41,6 +41,8 @@ public override TestResult Execute(TestExecutionContext context) { SetupTestCaseInfo(serviceCollection, testMethod, knownAttributes); + SeedEnvironment(serviceCollection, method, knownAttributes); + SetupModules(serviceCollection, method, knownAttributes); SetupServiceSetupAttributes(moduleContext, serviceCollection, knownAttributes); @@ -143,6 +145,25 @@ private static void SetupServiceSetupAttributes( } } + /// + /// Registers the environment the test's attributes declare, ahead of the modules - the same + /// seeding the xUnit integration does, for the reason recorded there: conditions are answered + /// from the environment already in the collection as each module is applied, and the + /// service-setup pass runs too late to supply it. + /// + private static void SeedEnvironment( + IServiceCollection serviceCollection, MethodInfo method, Attribute[] knownAttributes) { + IModuleEnvironment? environment = null; + + foreach (var provider in knownAttributes.OfType()) { + environment = provider.ProvideEnvironment(method) ?? environment; + } + + if (environment != null) { + serviceCollection.Add(new ServiceDescriptor(typeof(IModuleEnvironment), environment)); + } + } + /// /// The same loading the xUnit integration does, reading so /// neither names the other's attribute. It is not shared code because it needs both diff --git a/src/DependencyModules.Runtime/Interfaces/IModuleEnvironmentProvider.cs b/src/DependencyModules.Runtime/Interfaces/IModuleEnvironmentProvider.cs new file mode 100644 index 0000000..a6ac21a --- /dev/null +++ b/src/DependencyModules.Runtime/Interfaces/IModuleEnvironmentProvider.cs @@ -0,0 +1,38 @@ +using System.Reflection; + +namespace DependencyModules.Runtime.Interfaces; + +/// +/// An attribute that names the environment a test's container is built for, before any module +/// is applied. +/// +/// +/// +/// Module registrations are conditioned as they are applied: [IfEnvironment] and its +/// siblings are answered from the already in the collection, +/// or from a process default when there is none. The test integrations run their service-setup +/// pass after the modules by design - a test registration beats an application one - so an +/// environment registered there arrives after every condition has been decided against the +/// default, and a test had no way to put itself under the environment it declares. The +/// integrations consult this before loading any module, which is the whole difference. +/// +/// +/// Beside rather than in the Testing package, for the +/// same reason the module loading it feeds lives in each integration: it returns a Runtime +/// type, and Testing deliberately does not reference Runtime. The parameter is a +/// rather than a test-method context for the same reason. +/// +/// +/// Attributes are consulted widest scope first - assembly, then class, then method - and the +/// narrowest one that answers decides, matching how every other attribute resolves. +/// +/// +public interface IModuleEnvironmentProvider { + + /// + /// The environment module conditions are evaluated against, or null to leave the decision + /// to a wider scope. + /// + /// The test method the container is being built for. + IModuleEnvironment? ProvideEnvironment(MethodInfo testMethod); +} diff --git a/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs b/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs index f1548c7..1ac6a98 100644 --- a/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs +++ b/src/DependencyModules.xUnit/Impl/ModuleTestCase.cs @@ -84,6 +84,8 @@ private async Task SetupServiceCollection() { SetupTestCaseInfo(serviceCollection, knownAttributes); + SeedEnvironment(serviceCollection, knownAttributes); + SetupModules(serviceCollection, knownAttributes); SetupServiceSetupAttributes(context, serviceCollection, knownAttributes); @@ -160,6 +162,30 @@ private void SetupServiceSetupAttributes( } } + /// + /// Registers the environment the test's attributes declare, ahead of the modules. + /// + /// + /// Before , because module registrations are conditioned as they are + /// applied: LoadModules answers [IfEnvironment] from the + /// already in the collection, or a process default when there + /// is none. The service-setup pass runs after the modules by design - a test registration + /// beats an application one - so an environment registered there arrived after every condition + /// had been decided against the default. Widest scope first, so the narrowest attribute that + /// answers decides, matching how every other attribute here resolves. + /// + private void SeedEnvironment(IServiceCollection serviceCollection, Attribute[] knownAttributes) { + IModuleEnvironment? environment = null; + + foreach (var provider in knownAttributes.OfType()) { + environment = provider.ProvideEnvironment(TestMethod.Method) ?? environment; + } + + if (environment != null) { + serviceCollection.Add(new ServiceDescriptor(typeof(IModuleEnvironment), environment)); + } + } + private void SetupModules(ServiceCollection serviceCollection, IEnumerable knownAttributes) { var modules = new List(); diff --git a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.RuntimeApi.verified.txt b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.RuntimeApi.verified.txt index 8673bfc..7aa4569 100644 --- a/tests/DependencyModules.Tests/Snapshots/PublicApiTests.RuntimeApi.verified.txt +++ b/tests/DependencyModules.Tests/Snapshots/PublicApiTests.RuntimeApi.verified.txt @@ -350,6 +350,10 @@ namespace DependencyModules.Runtime.Interfaces string EnvironmentName { get; } string? Value(string name); } + public interface IModuleEnvironmentProvider + { + DependencyModules.Runtime.Interfaces.IModuleEnvironment? ProvideEnvironment(System.Reflection.MethodInfo testMethod); + } public interface IServiceCollectionConfiguration { void ConfigureDecorators(Microsoft.Extensions.DependencyInjection.IServiceCollection services);