From 4d9456bb5130c3fef4b595d7d2c63f84892cdb1a Mon Sep 17 00:00:00 2001 From: AidenFuller Date: Tue, 13 Jan 2026 13:11:35 +1100 Subject: [PATCH 1/4] Introduce ServiceProviderLifetimeType for scoped dependency container lifetimes and refactor related logic in ServiceCollectionFinder. --- .../DependencyInjectionPlugin.cs | 57 +++++++-- .../IServiceCollectionFinder.cs | 1 + .../ScenarioDependenciesAttribute.cs | 25 ++++ .../ServiceCollectionFinder.cs | 48 ++++++-- .../ServiceCollectionFinderTests.cs | 113 ++++++++++++++---- 5 files changed, 198 insertions(+), 46 deletions(-) diff --git a/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/DependencyInjectionPlugin.cs b/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/DependencyInjectionPlugin.cs index 3abf2b102..4e2dd98dc 100644 --- a/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/DependencyInjectionPlugin.cs +++ b/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/DependencyInjectionPlugin.cs @@ -29,6 +29,7 @@ public class DependencyInjectionPlugin : IRuntimePlugin public void Initialize(RuntimePluginEvents runtimePluginEvents, RuntimePluginParameters runtimePluginParameters, UnitTestProviderConfiguration unitTestProviderConfiguration) { runtimePluginEvents.CustomizeGlobalDependencies += CustomizeGlobalDependencies; + runtimePluginEvents.CustomizeTestThreadDependencies += CustomizeTestThreadDependenciesEventHandler; runtimePluginEvents.CustomizeFeatureDependencies += CustomizeFeatureDependenciesEventHandler; runtimePluginEvents.CustomizeScenarioDependencies += CustomizeScenarioDependenciesEventHandler; } @@ -45,21 +46,15 @@ private void CustomizeGlobalDependencies(object sender, CustomizeGlobalDependenc args.ObjectContainer.RegisterTypeAs(); } - // We store the (MS) service provider in the global (BoDi) container, we create it only once. - // It must be lazy (hence factory) because at this point we still don't have the bindings mapped. - args.ObjectContainer.RegisterFactoryAs(() => - { - var serviceCollectionFinder = args.ObjectContainer.Resolve(); - var (services, scoping) = serviceCollectionFinder.GetServiceCollection(); - - RegisterProxyBindings(args.ObjectContainer, services); - return new RootServiceProviderContainer(services.BuildServiceProvider(), scoping); - }); + var serviceCollectionFinder = args.ObjectContainer.Resolve(); + var lifetime = serviceCollectionFinder.GetServiceProviderLifetime(); - args.ObjectContainer.RegisterFactoryAs(() => + if (lifetime == ServiceProviderLifetimeType.Global) { - return args.ObjectContainer.Resolve().ServiceProvider; - }); + args.ObjectContainer.RegisterFactoryAs(() => BuildRootServiceProviderContainer(args.ObjectContainer)); + } + + args.ObjectContainer.RegisterFactoryAs(c => c.Resolve().ServiceProvider); // Will make sure DI scope is disposed. var lcEvents = args.ObjectContainer.Resolve(); @@ -70,8 +65,27 @@ private void CustomizeGlobalDependencies(object sender, CustomizeGlobalDependenc } } + private static void CustomizeTestThreadDependenciesEventHandler(object sender, CustomizeTestThreadDependenciesEventArgs args) + { + var serviceCollectionFinder = args.ObjectContainer.Resolve(); + var lifetime = serviceCollectionFinder.GetServiceProviderLifetime(); + + if (lifetime == ServiceProviderLifetimeType.TestThread) + { + args.ObjectContainer.RegisterFactoryAs(() => BuildRootServiceProviderContainer(args.ObjectContainer)); + } + } + private static void CustomizeFeatureDependenciesEventHandler(object sender, CustomizeFeatureDependenciesEventArgs args) { + var serviceCollectionFinder = args.ObjectContainer.Resolve(); + var lifetime = serviceCollectionFinder.GetServiceProviderLifetime(); + + if (lifetime == ServiceProviderLifetimeType.Feature) + { + args.ObjectContainer.RegisterFactoryAs(() => BuildRootServiceProviderContainer(args.ObjectContainer)); + } + // At this point we have the bindings, we can resolve the service provider, which will build it if it's the first time. var spContainer = args.ObjectContainer.Resolve(); @@ -101,6 +115,14 @@ private static void AfterFeaturePluginLifecycleEventHandler(object sender, Runti private static void CustomizeScenarioDependenciesEventHandler(object sender, CustomizeScenarioDependenciesEventArgs args) { + var serviceCollectionFinder = args.ObjectContainer.Resolve(); + var lifetime = serviceCollectionFinder.GetServiceProviderLifetime(); + + if (lifetime == ServiceProviderLifetimeType.Scenario) + { + args.ObjectContainer.RegisterFactoryAs(() => BuildRootServiceProviderContainer(args.ObjectContainer)); + } + // At this point we have the bindings, we can resolve the service provider, which will build it if it's the first time. var spContainer = args.ObjectContainer.Resolve(); @@ -127,6 +149,15 @@ private static void AfterScenarioPluginLifecycleEventHandler(object sender, Runt } } + private static RootServiceProviderContainer BuildRootServiceProviderContainer(IObjectContainer container) + { + var serviceCollectionFinder = container.Resolve(); + var (services, scoping) = serviceCollectionFinder.GetServiceCollection(); + + RegisterProxyBindings(container, services); + return new RootServiceProviderContainer(services.BuildServiceProvider(), scoping); + } + private static void RegisterProxyBindings(IObjectContainer objectContainer, IServiceCollection services) { // Required for DI of binding classes that want container injections diff --git a/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/IServiceCollectionFinder.cs b/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/IServiceCollectionFinder.cs index 707b2c2f1..0b3ff8b9f 100644 --- a/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/IServiceCollectionFinder.cs +++ b/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/IServiceCollectionFinder.cs @@ -4,6 +4,7 @@ namespace Reqnroll.Microsoft.Extensions.DependencyInjection { public interface IServiceCollectionFinder { + ServiceProviderLifetimeType GetServiceProviderLifetime(); (IServiceCollection, ScopeLevelType) GetServiceCollection(); } } diff --git a/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ScenarioDependenciesAttribute.cs b/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ScenarioDependenciesAttribute.cs index 3bcfbdb8e..e23a014b9 100644 --- a/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ScenarioDependenciesAttribute.cs +++ b/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ScenarioDependenciesAttribute.cs @@ -14,6 +14,26 @@ public enum ScopeLevelType Feature } + public enum ServiceProviderLifetimeType + { + /// + /// Global lifetime. The container is created once for the entire test run. + /// + Global, + /// + /// Test thread lifetime. The container is created once for each test thread. + /// + TestThread, + /// + /// Feature lifetime. The container is created once for each feature. + /// + Feature, + /// + /// Scenario lifetime. The container is created once for each scenario. + /// + Scenario + } + [AttributeUsage(AttributeTargets.Method)] public class ScenarioDependenciesAttribute : Attribute { @@ -26,5 +46,10 @@ public class ScenarioDependenciesAttribute : Attribute /// Define when to create and destroy scope. /// public ScopeLevelType ScopeLevel { get; set; } = ScopeLevelType.Scenario; + + /// + /// Define the lifetime of the Service Provider instance. + /// + public ServiceProviderLifetimeType ServiceProviderLifetime { get; set; } = ServiceProviderLifetimeType.Global; } } diff --git a/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ServiceCollectionFinder.cs b/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ServiceCollectionFinder.cs index 5ed766791..e430adc33 100644 --- a/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ServiceCollectionFinder.cs +++ b/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ServiceCollectionFinder.cs @@ -3,32 +3,58 @@ using System.Linq; using System.Reflection; using Microsoft.Extensions.DependencyInjection; +using Reqnroll.Bindings.Discovery; +using Reqnroll.Infrastructure; namespace Reqnroll.Microsoft.Extensions.DependencyInjection { public class ServiceCollectionFinder : IServiceCollectionFinder { - private readonly ITestRunnerManager testRunnerManager; - private (IServiceCollection, ScopeLevelType) _cache; + private readonly ITestRunnerManager _testRunnerManager; + private readonly IRuntimeBindingRegistryBuilder _bindingRegistryBuilder; + private readonly ITestAssemblyProvider _testAssemblyProvider; + private (IServiceCollection ServiceCollection, ScenarioDependenciesAttribute Attribute) _cache; - public ServiceCollectionFinder(ITestRunnerManager testRunnerManager) + public ServiceCollectionFinder(ITestRunnerManager testRunnerManager, IRuntimeBindingRegistryBuilder bindingRegistryBuilder, ITestAssemblyProvider testAssemblyProvider, IBindingAssemblyLoader bindingAssemblyLoader) { - this.testRunnerManager = testRunnerManager; + _testRunnerManager = testRunnerManager; + _bindingRegistryBuilder = bindingRegistryBuilder; + _testAssemblyProvider = testAssemblyProvider; + } + + public ServiceProviderLifetimeType GetServiceProviderLifetime() + { + if (_cache == default) + { + PopulateCache(); + } + + return _cache.Attribute.ServiceProviderLifetime; } public (IServiceCollection, ScopeLevelType) GetServiceCollection() + { + if (_cache == default) + { + PopulateCache(); + } + + return (_cache.ServiceCollection, _cache.Attribute.ScopeLevel); + } + + private void PopulateCache() { if (_cache != default) { - return _cache; + return; } - var assemblies = testRunnerManager.BindingAssemblies; + var assemblies = _testRunnerManager.BindingAssemblies ?? _bindingRegistryBuilder.GetBindingAssemblies(_testAssemblyProvider.TestAssembly); foreach (var assembly in assemblies) { foreach (var type in assembly.GetTypes()) { - foreach (MethodInfo methodInfo in type.GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)) + foreach (var methodInfo in type.GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)) { var scenarioDependenciesAttribute = (ScenarioDependenciesAttribute)Attribute.GetCustomAttribute(methodInfo, typeof(ScenarioDependenciesAttribute)); @@ -39,7 +65,8 @@ public ServiceCollectionFinder(ITestRunnerManager testRunnerManager) { AddBindingAttributes(assemblies, serviceCollection); } - return _cache = (serviceCollection, scenarioDependenciesAttribute.ScopeLevel); + _cache = (serviceCollection, scenarioDependenciesAttribute); + return; } } } @@ -50,12 +77,13 @@ public ServiceCollectionFinder(ITestRunnerManager testRunnerManager) private static IServiceCollection GetServiceCollection(MethodInfo methodInfo) { - var serviceCollection = methodInfo.Invoke(null, null); - if(methodInfo.ReturnType == typeof(void)) + if (methodInfo.ReturnType == typeof(void)) { throw new InvalidScenarioDependenciesException("the method doesn't return a value."); } + var serviceCollection = methodInfo.Invoke(null, null); + if (serviceCollection == null) { throw new InvalidScenarioDependenciesException("returned null."); diff --git a/Tests/Reqnroll.PluginTests/Microsoft.Extensions.DependencyInjection/ServiceCollectionFinderTests.cs b/Tests/Reqnroll.PluginTests/Microsoft.Extensions.DependencyInjection/ServiceCollectionFinderTests.cs index 6a70828b0..064f39ee4 100644 --- a/Tests/Reqnroll.PluginTests/Microsoft.Extensions.DependencyInjection/ServiceCollectionFinderTests.cs +++ b/Tests/Reqnroll.PluginTests/Microsoft.Extensions.DependencyInjection/ServiceCollectionFinderTests.cs @@ -5,6 +5,8 @@ using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Moq; +using Reqnroll.Bindings.Discovery; +using Reqnroll.Infrastructure; using Reqnroll.Microsoft.Extensions.DependencyInjection; using Xunit; @@ -12,12 +14,16 @@ namespace Reqnroll.PluginTests.Microsoft.Extensions.DependencyInjection; public class ServiceCollectionFinderTests { + private readonly Mock _testRunnerManagerMock = new(); + private readonly Mock _bindingRegistryBuilderMock = new(); + private readonly Mock _testAssemblyProviderMock = new(); + private readonly Mock _bindingAssemblyLoaderMock = new(); + [Fact] public void GetServiceCollection_HappyPath_ResolvesCorrectServiceCollection() { // Arrange - var testRunnerManagerMock = CreateTestRunnerManagerMock(typeof(ValidStartWithAutoRegister)); - var sut = new ServiceCollectionFinder(testRunnerManagerMock.Object); + var sut = CreateServiceCollectionFinderWithMocks(typeof(ValidStartWithAutoRegister)); // Act var (serviceCollection, _) = sut.GetServiceCollection(); @@ -33,14 +39,14 @@ public void GetServiceCollection_HappyPath_ResolvesCorrectServiceCollection() public void GetServiceCollection_MethodIsVoid_ThrowsInvalidScenarioDependenciesException() { // Arrange - var testRunnerManagerMock = CreateTestRunnerManagerMock(typeof(InvalidStartVoid)); - var sut = new ServiceCollectionFinder(testRunnerManagerMock.Object); + var sut = CreateServiceCollectionFinderWithMocks(typeof(InvalidStartVoid)); // Act var act = () => sut.GetServiceCollection(); // Assert - act.Should().Throw() + act.Should() + .Throw() .WithMessage("[ScenarioDependencies] should return IServiceCollection but the method doesn't return a value."); } @@ -48,30 +54,29 @@ public void GetServiceCollection_MethodIsVoid_ThrowsInvalidScenarioDependenciesE public void GetServiceCollection_MethodReturnsNull_ThrowsInvalidScenarioDependenciesException() { // Arrange - var testRunnerManagerMock = CreateTestRunnerManagerMock(typeof(InvalidStartNull)); - var sut = new ServiceCollectionFinder(testRunnerManagerMock.Object); + var sut = CreateServiceCollectionFinderWithMocks(typeof(InvalidStartNull)); // Act var act = () => sut.GetServiceCollection(); // Assert - act.Should().Throw() + act.Should() + .Throw() .WithMessage("[ScenarioDependencies] should return IServiceCollection but returned null."); } - [Fact] public void GetServiceCollection_MethodReturnsInvalidType_ThrowsInvalidScenarioDependenciesException() { // Arrange - var testRunnerManagerMock = CreateTestRunnerManagerMock(typeof(InvalidStartWrongType)); - var sut = new ServiceCollectionFinder(testRunnerManagerMock.Object); + var sut = CreateServiceCollectionFinderWithMocks(typeof(InvalidStartWrongType)); // Act var act = () => sut.GetServiceCollection(); // Assert - act.Should().Throw() + act.Should() + .Throw() .WithMessage("[ScenarioDependencies] should return IServiceCollection but returned System.Collections.Generic.List`1[System.String]."); } @@ -79,14 +84,14 @@ public void GetServiceCollection_MethodReturnsInvalidType_ThrowsInvalidScenarioD public void GetServiceCollection_NotFound_ThrowsMissingScenarioDependenciesException() { // Arrange - var testRunnerManagerMock = CreateTestRunnerManagerMock(typeof(ServiceCollectionFinderTests)); - var sut = new ServiceCollectionFinder(testRunnerManagerMock.Object); + var sut = CreateServiceCollectionFinderWithMocks(typeof(ServiceCollectionFinderTests)); // Act var act = () => sut.GetServiceCollection(); // Assert - act.Should().Throw() + act.Should() + .Throw() .WithMessage("No method marked with [ScenarioDependencies] attribute found. It should be a (public or non-public) static method. Scanned assemblies: Reqnroll.PluginTests."); } @@ -94,8 +99,7 @@ public void GetServiceCollection_NotFound_ThrowsMissingScenarioDependenciesExcep public void GetServiceCollection_AutoRegisterBindingsTrue_RegisterBindingsAsScoped() { // Arrange - var testRunnerManagerMock = CreateTestRunnerManagerMock(typeof(ValidStartWithAutoRegister), typeof(Binding1)); - var sut = new ServiceCollectionFinder(testRunnerManagerMock.Object); + var sut = CreateServiceCollectionFinderWithMocks(typeof(ValidStartWithAutoRegister), typeof(Binding1)); // Act var (serviceCollection, _) = sut.GetServiceCollection(); @@ -109,8 +113,7 @@ public void GetServiceCollection_AutoRegisterBindingsTrue_RegisterBindingsAsScop public void GetServiceCollection_AutoRegisterBindingsFalse_DoNotRegisterBindings() { // Arrange - var testRunnerManagerMock = CreateTestRunnerManagerMock(typeof(ValidStartWithoutAutoRegister), typeof(Binding1)); - var sut = new ServiceCollectionFinder(testRunnerManagerMock.Object); + var sut = CreateServiceCollectionFinderWithMocks(typeof(ValidStartWithoutAutoRegister), typeof(Binding1)); // Act var (serviceCollection, _) = sut.GetServiceCollection(); @@ -120,7 +123,57 @@ public void GetServiceCollection_AutoRegisterBindingsFalse_DoNotRegisterBindings serviceCollection.Should().NotContain(d => d.ImplementationType == typeof(Binding1)); } - private static Mock CreateTestRunnerManagerMock(params Type[] types) + [Fact] + public void GetServiceCollection_CustomLifetime_ReturnsCorrectLifetime() + { + // Arrange + var sut = CreateServiceCollectionFinderWithMocks(typeof(ValidStartWithFeatureLifetime)); + + // Act + var (_, lifetime) = sut.GetServiceCollection(); + + // Assert + lifetime.Should().Be(ScopeLevelType.Feature); + } + + [Fact] + public void GetServiceCollection_BindingAssembliesNull_FallbacksToBindingRegistryBuilder() + { + // Arrange + var types = new[] { typeof(ValidStartWithAutoRegister) }; + var assemblyMock = new Mock(); + assemblyMock.Setup(m => m.GetTypes()).Returns(types.ToArray()); + var assembly = types[0].Assembly; + assemblyMock.Setup(m => m.GetName()).Returns(assembly.GetName()); + + _testRunnerManagerMock.Setup(m => m.BindingAssemblies).Returns((Assembly[])null); + _testAssemblyProviderMock.Setup(m => m.TestAssembly).Returns(assemblyMock.Object); + _bindingRegistryBuilderMock.Setup(m => m.GetBindingAssemblies(assemblyMock.Object)).Returns([assemblyMock.Object]); + + var sut = new ServiceCollectionFinder(_testRunnerManagerMock.Object, _bindingRegistryBuilderMock.Object, _testAssemblyProviderMock.Object, _bindingAssemblyLoaderMock.Object); + + // Act + var (serviceCollection, _) = sut.GetServiceCollection(); + + // Assert + serviceCollection.Should().NotBeNull(); + _bindingRegistryBuilderMock.Verify(m => m.GetBindingAssemblies(assemblyMock.Object), Times.Once); + } + + [Fact] + public void GetServiceProviderLifetime_ReturnsCorrectLifetime() + { + // Arrange + var sut = CreateServiceCollectionFinderWithMocks(typeof(ValidStartWithFeatureLifetime)); + + // Act + var lifetime = sut.GetServiceProviderLifetime(); + + // Assert + lifetime.Should().Be(ServiceProviderLifetimeType.Feature); + } + + private ServiceCollectionFinder CreateServiceCollectionFinderWithMocks(params Type[] types) { var assemblyMock = new Mock(); assemblyMock.Setup(m => m.GetTypes()).Returns(types.ToArray()); @@ -130,9 +183,20 @@ private static Mock CreateTestRunnerManagerMock(params Type[ assemblyMock.Setup(m => m.GetName()).Returns(assembly.GetName()); } - var testRunnerManagerMock = new Mock(); - testRunnerManagerMock.Setup(m => m.BindingAssemblies).Returns([assemblyMock.Object]); - return testRunnerManagerMock; + _testRunnerManagerMock.Setup(m => m.BindingAssemblies).Returns([assemblyMock.Object]); + _testAssemblyProviderMock.Setup(m => m.TestAssembly).Returns(assemblyMock.Object); + _bindingRegistryBuilderMock.Setup(m => m.GetBindingAssemblies(It.IsAny())).Returns([assemblyMock.Object]); + + return new ServiceCollectionFinder(_testRunnerManagerMock.Object, _bindingRegistryBuilderMock.Object, _testAssemblyProviderMock.Object, _bindingAssemblyLoaderMock.Object); + } + + private class ValidStartWithFeatureLifetime + { + [ScenarioDependencies(ScopeLevel = ScopeLevelType.Feature, ServiceProviderLifetime = ServiceProviderLifetimeType.Feature)] + public static IServiceCollection GetServices() + { + return new ServiceCollection(); + } } private interface ITestInterface @@ -152,6 +216,7 @@ public static IServiceCollection GetServices() return serviceCollection; } } + private class ValidStartWithoutAutoRegister { [ScenarioDependencies(AutoRegisterBindings = false)] @@ -161,6 +226,7 @@ public static IServiceCollection GetServices() return serviceCollection; } } + private class InvalidStartVoid { [ScenarioDependencies] @@ -188,6 +254,7 @@ public static object GetServices() return new List(); } } + [Binding] private class Binding1; } From 95086f7b42d9264ffc9740359ddc06e0f5f057ce Mon Sep 17 00:00:00 2001 From: AidenFuller Date: Tue, 13 Jan 2026 15:35:51 +1100 Subject: [PATCH 2/4] Document ServiceProviderLifetimeType configuration in CHANGELOG and DI integration guide. --- CHANGELOG.md | 5 ++- docs/integrations/dependency-injection.md | 38 +++++++++++++++++------ 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70110c668..055c31bc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,14 @@ ## Improvements: +* Microsoft.Extensions.DependencyInjection.ReqnrollPlugin: Added support for configurable service provider lifetimes to control at which scope the services are registered (#998) + * Options are Global (default, previous behavior), Test Thread, Feature, Scenario + ## Bug fixes: * Fix: Partially defined CI Environment variables (missing relevant environment variables) cause missing Meta envelope in Cucumber Messages report and Javascript errors in HTML report. (#990) -*Contributors of this release (in alphabetical order):* @clrudolphi +*Contributors of this release (in alphabetical order):* @AidenFuller, @clrudolphi # v3.3.1 - 2026-01-08 diff --git a/docs/integrations/dependency-injection.md b/docs/integrations/dependency-injection.md index cf69e5630..b49af0d47 100644 --- a/docs/integrations/dependency-injection.md +++ b/docs/integrations/dependency-injection.md @@ -7,19 +7,19 @@ Reqnroll plugin for using Microsoft.Extensions.DependencyInjection as a dependen Currently supports Microsoft.Extensions.DependencyInjection v6.0.0 or above ``` -## Step by step walkthrough of using Reqnroll.Microsoft.Extensions.DependencyInjection +## Install the plugin from NuGet into your Reqnroll project +Install the `Reqnroll.Microsoft.Extensions.DependencyInjection` NuGet package directly into your test project. -### 1. Install plugin from NuGet into your Reqnroll project. - -```csharp -PM> Install-Package Reqnroll.Microsoft.Extensions.DependencyInjection +```powershell +Install-Package Reqnroll.Microsoft.Extensions.DependencyInjection ``` -### 2. Create static methods somewhere in the Reqnroll project -Create a static method in your SpecFlow project that returns a Microsoft.Extensions.DependencyInjection.IServiceCollection and tag it with the [ScenarioDependencies] attribute. Configure your test dependencies for the scenario execution within this method. Step definition classes (i.e. classes with the SpecFlow [Binding] attribute) are automatically added to the service collection. - -### 3. A typical dependency builder method looks like this: +## Using the plugin + +Create a static, parameterless method in your Reqnroll project that returns an instance of `Microsoft.Extensions.DependencyInjection.IServiceCollection` and tag it with the `[ScenarioDependencies]` attribute. Configure your test dependencies for the scenario execution within this method. Step definition classes (i.e. classes with the Reqnroll `[Binding]` attribute) are automatically added to the service collection. + +A typical dependency builder method looks like this: ```csharp public class SetupTestDependencies @@ -36,3 +36,23 @@ public class SetupTestDependencies } } ``` + +### Configuring the scope and lifetime of the service provider + +For services registered with a scoped lifetime (as opposed to singleton), it might make sense to have a new scope for each scenario rather than each feature (the default). If this is the case, this can be adjusted with the `ScopeLevel` property on the `[ScenarioDependencies]` attribute. For example + +```csharp +[ScenarioDependencies(ScopeLevel = ScopeLevelType.Scenario)] +public static IServiceCollection CreateServices() +``` + +It's also possible to change the lifetime of the entire service provider, rather than just its scope. This is particularly useful when you want a new instance of a singleton service for each feature or each scenario. + +```csharp +[ScenarioDependencies(ServiceProviderLifetime = ServiceProviderLifetimeType.Feature)] +public static IServiceCollection CreateServices() +``` + +```{note} +If the `ServiceProviderLifetime` is set to `Scenario` then the `ScopeLevel` is implicitly `Scenario` as well. +``` \ No newline at end of file From 69a32bdf49226d204f26bfd7136cc6d4f1552eb0 Mon Sep 17 00:00:00 2001 From: AidenFuller Date: Wed, 14 Jan 2026 15:41:25 +1100 Subject: [PATCH 3/4] Refactor `ServiceCollectionFinder` to simplify dependencies and align `ScopeLevel` with `ServiceProviderLifetime`. Improve test coverage and update documentation. --- CHANGELOG.md | 3 +- .../ServiceCollectionFinder.cs | 9 +- .../ServiceCollectionFinderTests.cs | 93 ++++++++++++++++--- docs/integrations/dependency-injection.md | 6 +- 4 files changed, 92 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 055c31bc0..bb70f9bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,7 @@ ## Improvements: -* Microsoft.Extensions.DependencyInjection.ReqnrollPlugin: Added support for configurable service provider lifetimes to control at which scope the services are registered (#998) - * Options are Global (default, previous behavior), Test Thread, Feature, Scenario +* Microsoft.Extensions.DependencyInjection.ReqnrollPlugin: Added support for configurable service provider lifetimes to control which level the services are registered. Options are Global (default, previous behavior), Test Thread, Feature, Scenario. (#998) ## Bug fixes: diff --git a/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ServiceCollectionFinder.cs b/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ServiceCollectionFinder.cs index e430adc33..0fa2fe8fa 100644 --- a/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ServiceCollectionFinder.cs +++ b/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ServiceCollectionFinder.cs @@ -15,7 +15,7 @@ public class ServiceCollectionFinder : IServiceCollectionFinder private readonly ITestAssemblyProvider _testAssemblyProvider; private (IServiceCollection ServiceCollection, ScenarioDependenciesAttribute Attribute) _cache; - public ServiceCollectionFinder(ITestRunnerManager testRunnerManager, IRuntimeBindingRegistryBuilder bindingRegistryBuilder, ITestAssemblyProvider testAssemblyProvider, IBindingAssemblyLoader bindingAssemblyLoader) + public ServiceCollectionFinder(ITestRunnerManager testRunnerManager, IRuntimeBindingRegistryBuilder bindingRegistryBuilder, ITestAssemblyProvider testAssemblyProvider) { _testRunnerManager = testRunnerManager; _bindingRegistryBuilder = bindingRegistryBuilder; @@ -65,6 +65,13 @@ private void PopulateCache() { AddBindingAttributes(assemblies, serviceCollection); } + + // If the ServiceProviderLifetime is Scenario, set the ScopeLevel to Scenario to match. + if (scenarioDependenciesAttribute.ServiceProviderLifetime == ServiceProviderLifetimeType.Scenario) + { + scenarioDependenciesAttribute.ScopeLevel = ScopeLevelType.Scenario; + } + _cache = (serviceCollection, scenarioDependenciesAttribute); return; } diff --git a/Tests/Reqnroll.PluginTests/Microsoft.Extensions.DependencyInjection/ServiceCollectionFinderTests.cs b/Tests/Reqnroll.PluginTests/Microsoft.Extensions.DependencyInjection/ServiceCollectionFinderTests.cs index 064f39ee4..0c9964ddc 100644 --- a/Tests/Reqnroll.PluginTests/Microsoft.Extensions.DependencyInjection/ServiceCollectionFinderTests.cs +++ b/Tests/Reqnroll.PluginTests/Microsoft.Extensions.DependencyInjection/ServiceCollectionFinderTests.cs @@ -17,7 +17,6 @@ public class ServiceCollectionFinderTests private readonly Mock _testRunnerManagerMock = new(); private readonly Mock _bindingRegistryBuilderMock = new(); private readonly Mock _testAssemblyProviderMock = new(); - private readonly Mock _bindingAssemblyLoaderMock = new(); [Fact] public void GetServiceCollection_HappyPath_ResolvesCorrectServiceCollection() @@ -150,7 +149,7 @@ public void GetServiceCollection_BindingAssembliesNull_FallbacksToBindingRegistr _testAssemblyProviderMock.Setup(m => m.TestAssembly).Returns(assemblyMock.Object); _bindingRegistryBuilderMock.Setup(m => m.GetBindingAssemblies(assemblyMock.Object)).Returns([assemblyMock.Object]); - var sut = new ServiceCollectionFinder(_testRunnerManagerMock.Object, _bindingRegistryBuilderMock.Object, _testAssemblyProviderMock.Object, _bindingAssemblyLoaderMock.Object); + var sut = new ServiceCollectionFinder(_testRunnerManagerMock.Object, _bindingRegistryBuilderMock.Object, _testAssemblyProviderMock.Object); // Act var (serviceCollection, _) = sut.GetServiceCollection(); @@ -161,7 +160,33 @@ public void GetServiceCollection_BindingAssembliesNull_FallbacksToBindingRegistr } [Fact] - public void GetServiceProviderLifetime_ReturnsCorrectLifetime() + public void GetServiceProviderLifetime_GlobalLifetime_ReturnsCorrectLifetime() + { + // Arrange + var sut = CreateServiceCollectionFinderWithMocks(typeof(ValidStartWithGlobalLifetime)); + + // Act + var lifetime = sut.GetServiceProviderLifetime(); + + // Assert + lifetime.Should().Be(ServiceProviderLifetimeType.Global); + } + + [Fact] + public void GetServiceProviderLifetime_TestThreadLifetime_ReturnsCorrectLifetime() + { + // Arrange + var sut = CreateServiceCollectionFinderWithMocks(typeof(ValidStartWithTestThreadLifetime)); + + // Act + var lifetime = sut.GetServiceProviderLifetime(); + + // Assert + lifetime.Should().Be(ServiceProviderLifetimeType.TestThread); + } + + [Fact] + public void GetServiceProviderLifetime_FeatureLifetime_ReturnsCorrectLifetime() { // Arrange var sut = CreateServiceCollectionFinderWithMocks(typeof(ValidStartWithFeatureLifetime)); @@ -173,6 +198,21 @@ public void GetServiceProviderLifetime_ReturnsCorrectLifetime() lifetime.Should().Be(ServiceProviderLifetimeType.Feature); } + [Fact] + public void GetServiceProviderLifetime_ScenarioLifetime_ReturnsCorrectLifetime() + { + // Arrange + var sut = CreateServiceCollectionFinderWithMocks(typeof(ValidStartWithScenarioLifetime)); + + // Act + var lifetime = sut.GetServiceProviderLifetime(); + var (_, scope) = sut.GetServiceCollection(); + + // Assert + lifetime.Should().Be(ServiceProviderLifetimeType.Scenario); + scope.Should().Be(ScopeLevelType.Scenario, "because the ServiceProviderLifetime is Scenario."); + } + private ServiceCollectionFinder CreateServiceCollectionFinderWithMocks(params Type[] types) { var assemblyMock = new Mock(); @@ -187,16 +227,7 @@ private ServiceCollectionFinder CreateServiceCollectionFinderWithMocks(params Ty _testAssemblyProviderMock.Setup(m => m.TestAssembly).Returns(assemblyMock.Object); _bindingRegistryBuilderMock.Setup(m => m.GetBindingAssemblies(It.IsAny())).Returns([assemblyMock.Object]); - return new ServiceCollectionFinder(_testRunnerManagerMock.Object, _bindingRegistryBuilderMock.Object, _testAssemblyProviderMock.Object, _bindingAssemblyLoaderMock.Object); - } - - private class ValidStartWithFeatureLifetime - { - [ScenarioDependencies(ScopeLevel = ScopeLevelType.Feature, ServiceProviderLifetime = ServiceProviderLifetimeType.Feature)] - public static IServiceCollection GetServices() - { - return new ServiceCollection(); - } + return new ServiceCollectionFinder(_testRunnerManagerMock.Object, _bindingRegistryBuilderMock.Object, _testAssemblyProviderMock.Object); } private interface ITestInterface @@ -255,6 +286,42 @@ public static object GetServices() } } + private class ValidStartWithGlobalLifetime + { + [ScenarioDependencies(ScopeLevel = ScopeLevelType.Feature, ServiceProviderLifetime = ServiceProviderLifetimeType.Global)] + public static IServiceCollection GetServices() + { + return new ServiceCollection(); + } + } + + private class ValidStartWithTestThreadLifetime + { + [ScenarioDependencies(ScopeLevel = ScopeLevelType.Feature, ServiceProviderLifetime = ServiceProviderLifetimeType.TestThread)] + public static IServiceCollection GetServices() + { + return new ServiceCollection(); + } + } + + private class ValidStartWithFeatureLifetime + { + [ScenarioDependencies(ScopeLevel = ScopeLevelType.Feature, ServiceProviderLifetime = ServiceProviderLifetimeType.Feature)] + public static IServiceCollection GetServices() + { + return new ServiceCollection(); + } + } + + private class ValidStartWithScenarioLifetime + { + [ScenarioDependencies(ScopeLevel = ScopeLevelType.Feature, ServiceProviderLifetime = ServiceProviderLifetimeType.Scenario)] + public static IServiceCollection GetServices() + { + return new ServiceCollection(); + } + } + [Binding] private class Binding1; } diff --git a/docs/integrations/dependency-injection.md b/docs/integrations/dependency-injection.md index b49af0d47..69db013b6 100644 --- a/docs/integrations/dependency-injection.md +++ b/docs/integrations/dependency-injection.md @@ -39,10 +39,10 @@ public class SetupTestDependencies ### Configuring the scope and lifetime of the service provider -For services registered with a scoped lifetime (as opposed to singleton), it might make sense to have a new scope for each scenario rather than each feature (the default). If this is the case, this can be adjusted with the `ScopeLevel` property on the `[ScenarioDependencies]` attribute. For example +For services registered with a scoped lifetime (as opposed to singleton), it might make sense to have a new scope for each feature instead of the default per-scenario scope. If this is the case, this can be adjusted with the `ScopeLevel` property on the `[ScenarioDependencies]` attribute. For example ```csharp -[ScenarioDependencies(ScopeLevel = ScopeLevelType.Scenario)] +[ScenarioDependencies(ScopeLevel = ScopeLevelType.Feature)] public static IServiceCollection CreateServices() ``` @@ -54,5 +54,5 @@ public static IServiceCollection CreateServices() ``` ```{note} -If the `ServiceProviderLifetime` is set to `Scenario` then the `ScopeLevel` is implicitly `Scenario` as well. +`ServiceProviderLifetime` and `ScopeLevel` are configured independently. If the `ServiceProviderLifetime` is set to `Scenario` then the `ScopeLevel` is implicitly `Scenario` as well. ``` \ No newline at end of file From 3e9520954a1236c778b227e2ac3841089639620c Mon Sep 17 00:00:00 2001 From: AidenFuller Date: Wed, 14 Jan 2026 15:44:55 +1100 Subject: [PATCH 4/4] Add thread-safety to `ServiceCollectionFinder` by introducing a cache lock --- .../ServiceCollectionFinder.cs | 49 +++++++++++-------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ServiceCollectionFinder.cs b/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ServiceCollectionFinder.cs index 0fa2fe8fa..f6513f079 100644 --- a/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ServiceCollectionFinder.cs +++ b/Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ServiceCollectionFinder.cs @@ -14,6 +14,7 @@ public class ServiceCollectionFinder : IServiceCollectionFinder private readonly IRuntimeBindingRegistryBuilder _bindingRegistryBuilder; private readonly ITestAssemblyProvider _testAssemblyProvider; private (IServiceCollection ServiceCollection, ScenarioDependenciesAttribute Attribute) _cache; + private readonly object _cacheLock = new(); public ServiceCollectionFinder(ITestRunnerManager testRunnerManager, IRuntimeBindingRegistryBuilder bindingRegistryBuilder, ITestAssemblyProvider testAssemblyProvider) { @@ -49,37 +50,45 @@ private void PopulateCache() return; } - var assemblies = _testRunnerManager.BindingAssemblies ?? _bindingRegistryBuilder.GetBindingAssemblies(_testAssemblyProvider.TestAssembly); - foreach (var assembly in assemblies) + lock (_cacheLock) { - foreach (var type in assembly.GetTypes()) + if (_cache != default) { - foreach (var methodInfo in type.GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)) - { - var scenarioDependenciesAttribute = (ScenarioDependenciesAttribute)Attribute.GetCustomAttribute(methodInfo, typeof(ScenarioDependenciesAttribute)); + return; + } - if (scenarioDependenciesAttribute != null) + var assemblies = _testRunnerManager.BindingAssemblies ?? _bindingRegistryBuilder.GetBindingAssemblies(_testAssemblyProvider.TestAssembly); + foreach (var assembly in assemblies) + { + foreach (var type in assembly.GetTypes()) + { + foreach (var methodInfo in type.GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public)) { - var serviceCollection = GetServiceCollection(methodInfo); - if (scenarioDependenciesAttribute.AutoRegisterBindings) - { - AddBindingAttributes(assemblies, serviceCollection); - } + var scenarioDependenciesAttribute = (ScenarioDependenciesAttribute)Attribute.GetCustomAttribute(methodInfo, typeof(ScenarioDependenciesAttribute)); - // If the ServiceProviderLifetime is Scenario, set the ScopeLevel to Scenario to match. - if (scenarioDependenciesAttribute.ServiceProviderLifetime == ServiceProviderLifetimeType.Scenario) + if (scenarioDependenciesAttribute != null) { - scenarioDependenciesAttribute.ScopeLevel = ScopeLevelType.Scenario; + var serviceCollection = GetServiceCollection(methodInfo); + if (scenarioDependenciesAttribute.AutoRegisterBindings) + { + AddBindingAttributes(assemblies, serviceCollection); + } + + // If the ServiceProviderLifetime is Scenario, set the ScopeLevel to Scenario to match. + if (scenarioDependenciesAttribute.ServiceProviderLifetime == ServiceProviderLifetimeType.Scenario) + { + scenarioDependenciesAttribute.ScopeLevel = ScopeLevelType.Scenario; + } + + _cache = (serviceCollection, scenarioDependenciesAttribute); + return; } - - _cache = (serviceCollection, scenarioDependenciesAttribute); - return; } } } + var assemblyNames = assemblies.Select(a => a.GetName().Name).ToList(); + throw new MissingScenarioDependenciesException(assemblyNames); } - var assemblyNames = assemblies.Select(a => a.GetName().Name).ToList(); - throw new MissingScenarioDependenciesException(assemblyNames); } private static IServiceCollection GetServiceCollection(MethodInfo methodInfo)