Skip to content
Open
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
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@

## Improvements:

* 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:

* 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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -45,21 +46,15 @@ private void CustomizeGlobalDependencies(object sender, CustomizeGlobalDependenc
args.ObjectContainer.RegisterTypeAs<ServiceCollectionFinder, IServiceCollectionFinder>();
}

// 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<RootServiceProviderContainer>(() =>
{
var serviceCollectionFinder = args.ObjectContainer.Resolve<IServiceCollectionFinder>();
var (services, scoping) = serviceCollectionFinder.GetServiceCollection();

RegisterProxyBindings(args.ObjectContainer, services);
return new RootServiceProviderContainer(services.BuildServiceProvider(), scoping);
});
var serviceCollectionFinder = args.ObjectContainer.Resolve<IServiceCollectionFinder>();
var lifetime = serviceCollectionFinder.GetServiceProviderLifetime();

args.ObjectContainer.RegisterFactoryAs<IServiceProvider>(() =>
if (lifetime == ServiceProviderLifetimeType.Global)
{
return args.ObjectContainer.Resolve<RootServiceProviderContainer>().ServiceProvider;
});
args.ObjectContainer.RegisterFactoryAs<RootServiceProviderContainer>(() => BuildRootServiceProviderContainer(args.ObjectContainer));
}

args.ObjectContainer.RegisterFactoryAs<IServiceProvider>(c => c.Resolve<RootServiceProviderContainer>().ServiceProvider);

// Will make sure DI scope is disposed.
var lcEvents = args.ObjectContainer.Resolve<RuntimePluginTestExecutionLifecycleEvents>();
Expand All @@ -70,8 +65,27 @@ private void CustomizeGlobalDependencies(object sender, CustomizeGlobalDependenc
}
}

private static void CustomizeTestThreadDependenciesEventHandler(object sender, CustomizeTestThreadDependenciesEventArgs args)
{
var serviceCollectionFinder = args.ObjectContainer.Resolve<IServiceCollectionFinder>();
var lifetime = serviceCollectionFinder.GetServiceProviderLifetime();

if (lifetime == ServiceProviderLifetimeType.TestThread)
{
args.ObjectContainer.RegisterFactoryAs<RootServiceProviderContainer>(() => BuildRootServiceProviderContainer(args.ObjectContainer));
}
}
Comment on lines +68 to +77
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new CustomizeTestThreadDependenciesEventHandler method lacks any test coverage. Since this is a new method that handles the TestThread lifetime configuration, it should have corresponding tests to verify that the RootServiceProviderContainer is registered correctly when ServiceProviderLifetime is set to TestThread.

Copilot uses AI. Check for mistakes.

private static void CustomizeFeatureDependenciesEventHandler(object sender, CustomizeFeatureDependenciesEventArgs args)
{
var serviceCollectionFinder = args.ObjectContainer.Resolve<IServiceCollectionFinder>();
var lifetime = serviceCollectionFinder.GetServiceProviderLifetime();

if (lifetime == ServiceProviderLifetimeType.Feature)
{
args.ObjectContainer.RegisterFactoryAs<RootServiceProviderContainer>(() => 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<RootServiceProviderContainer>();

Expand Down Expand Up @@ -101,6 +115,14 @@ private static void AfterFeaturePluginLifecycleEventHandler(object sender, Runti

private static void CustomizeScenarioDependenciesEventHandler(object sender, CustomizeScenarioDependenciesEventArgs args)
{
var serviceCollectionFinder = args.ObjectContainer.Resolve<IServiceCollectionFinder>();
var lifetime = serviceCollectionFinder.GetServiceProviderLifetime();

if (lifetime == ServiceProviderLifetimeType.Scenario)
{
args.ObjectContainer.RegisterFactoryAs<RootServiceProviderContainer>(() => 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<RootServiceProviderContainer>();

Expand All @@ -127,6 +149,15 @@ private static void AfterScenarioPluginLifecycleEventHandler(object sender, Runt
}
}

private static RootServiceProviderContainer BuildRootServiceProviderContainer(IObjectContainer container)
{
var serviceCollectionFinder = container.Resolve<IServiceCollectionFinder>();
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace Reqnroll.Microsoft.Extensions.DependencyInjection
{
public interface IServiceCollectionFinder
{
ServiceProviderLifetimeType GetServiceProviderLifetime();
(IServiceCollection, ScopeLevelType) GetServiceCollection();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,26 @@ public enum ScopeLevelType
Feature
}

public enum ServiceProviderLifetimeType
{
/// <summary>
/// Global lifetime. The container is created once for the entire test run.
/// </summary>
Global,
/// <summary>
/// Test thread lifetime. The container is created once for each test thread.
/// </summary>
TestThread,
/// <summary>
/// Feature lifetime. The container is created once for each feature.
/// </summary>
Feature,
/// <summary>
/// Scenario lifetime. The container is created once for each scenario.
/// </summary>
Scenario
}

[AttributeUsage(AttributeTargets.Method)]
public class ScenarioDependenciesAttribute : Attribute
{
Expand All @@ -26,5 +46,10 @@ public class ScenarioDependenciesAttribute : Attribute
/// Define when to create and destroy scope.
/// </summary>
public ScopeLevelType ScopeLevel { get; set; } = ScopeLevelType.Scenario;

/// <summary>
/// Define the lifetime of the Service Provider instance.
/// </summary>
public ServiceProviderLifetimeType ServiceProviderLifetime { get; set; } = ServiceProviderLifetimeType.Global;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,59 +3,103 @@
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;
private readonly object _cacheLock = new();

public ServiceCollectionFinder(ITestRunnerManager testRunnerManager)
public ServiceCollectionFinder(ITestRunnerManager testRunnerManager, IRuntimeBindingRegistryBuilder bindingRegistryBuilder, ITestAssemblyProvider testAssemblyProvider)
{
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;
}
Comment on lines +46 to 51
Copy link

Copilot AI Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _cache field is accessed and modified without any synchronization mechanism. In concurrent test execution scenarios, multiple threads could potentially call GetServiceProviderLifetime() or GetServiceCollection() simultaneously, leading to race conditions in PopulateCache(). The check at line 47 and the assignment at line 68 are not atomic, which could result in PopulateCache() being executed multiple times or cache being accessed before it's fully populated. Consider adding thread synchronization (e.g., lock statement or Lazy) to ensure thread-safe cache initialization.

Copilot uses AI. Check for mistakes.

var assemblies = testRunnerManager.BindingAssemblies;
foreach (var assembly in assemblies)
lock (_cacheLock)
{
foreach (var type in assembly.GetTypes())
if (_cache != default)
{
foreach (MethodInfo 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)
var scenarioDependenciesAttribute = (ScenarioDependenciesAttribute)Attribute.GetCustomAttribute(methodInfo, typeof(ScenarioDependenciesAttribute));

if (scenarioDependenciesAttribute != null)
{
AddBindingAttributes(assemblies, serviceCollection);
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;
}
return _cache = (serviceCollection, scenarioDependenciesAttribute.ScopeLevel);
}
}
}
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)
{
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.");
Expand Down
Loading
Loading