Skip to content

Conversation

@AidenFuller
Copy link
Contributor

@AidenFuller AidenFuller commented Jan 13, 2026

🤔 What's changed?

The Microsoft Dependency Injection plugin for Reqnroll provides no way of limiting the lifetime of the service provider, with the current implementation being at a global level. Some tests would benefit from this container being rebuilt at a feature or scenario level. This implementation adds that ability with a new property on the ScenarioDependenciesAttribute class, and uses that throughout the rest of the plugin stack.

⚡️ What's your motivation?

See above, since I have projects that would benefit from this :)

🏷️ What kind of change is this?

  • ⚡ New feature (non-breaking change which adds new behaviour)

♻️ Anything particular you want feedback on?

Correctness :)

📋 Checklist:

  • I've changed the behaviour of the code
    • I have added/updated tests to cover my changes.
  • My change requires a change to the documentation.
    • I have updated the documentation accordingly.
  • Users should know about my change
    • I have added an entry to the "[vNext]" section of the CHANGELOG, linking to this pull request & included my GitHub handle to the release contributors list.

}

var assemblies = testRunnerManager.BindingAssemblies;
var assemblies = _testRunnerManager.BindingAssemblies ?? _bindingRegistryBuilder.GetBindingAssemblies(_testAssemblyProvider.TestAssembly);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not 100% sure whether we still need the _testRunnerManager.BindingAssemblies here if we've got the other fallback.

@AidenFuller
Copy link
Contributor Author

TODO:

  • Add validation for making sure that the service provider's lifetime is not more narrow than the scope level
  • Update documentation for the plugin
  • Add changes to the changelog

@AidenFuller AidenFuller force-pushed the 716-di-service-provider-lifetime branch from 98ec6a3 to 95086f7 Compare January 13, 2026 04:39
```

## Step by step walkthrough of using Reqnroll.Microsoft.Extensions.DependencyInjection
## Install the plugin from NuGet into your Reqnroll project
Copy link
Contributor Author

@AidenFuller AidenFuller Jan 13, 2026

Choose a reason for hiding this comment

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

I restructured this documentation a bit because it wasn't flowing super well

@AidenFuller AidenFuller marked this pull request as ready for review January 13, 2026 04:40
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request introduces the ServiceProviderLifetimeType enum to control when the Microsoft Dependency Injection service provider is created and destroyed, extending beyond the previous global-only lifetime. The implementation adds support for Global (default), TestThread, Feature, and Scenario lifetimes.

Changes:

  • Added ServiceProviderLifetimeType enum with four lifetime options
  • Extended ScenarioDependenciesAttribute with ServiceProviderLifetime property
  • Modified ServiceCollectionFinder to cache and expose the configured lifetime
  • Updated DependencyInjectionPlugin to register service providers at appropriate lifecycle events based on configured lifetime
  • Enhanced documentation with usage examples and explanations

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
docs/integrations/dependency-injection.md Added documentation for new ScopeLevel and ServiceProviderLifetime configuration options
Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ScenarioDependenciesAttribute.cs Added ServiceProviderLifetimeType enum and ServiceProviderLifetime property
Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/IServiceCollectionFinder.cs Added GetServiceProviderLifetime method to interface
Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/ServiceCollectionFinder.cs Implemented lifetime caching and retrieval, refactored cache structure
Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/DependencyInjectionPlugin.cs Added lifecycle event handlers for TestThread, Feature, and Scenario lifetimes
Tests/Reqnroll.PluginTests/Microsoft.Extensions.DependencyInjection/ServiceCollectionFinderTests.cs Added tests for custom lifetime retrieval and binding assembly fallback
CHANGELOG.md Documented the new feature and added contributor
Comments suppressed due to low confidence (1)

Plugins/Reqnroll.Microsoft.Extensions.DependencyInjection.ReqnrollPlugin/DependencyInjectionPlugin.cs:140

  • There is a potential issue with the scope management behavior when ServiceProviderLifetime is set to Scenario. When a new RootServiceProviderContainer is created for each scenario (lines 121-124), but then the code also checks if spContainer.Scoping == ScopeLevelType.Scenario (line 129) to create additional scopes. This could lead to confusion because:
  1. If ServiceProviderLifetime is Scenario, the entire provider is rebuilt per scenario
  2. The ScopeLevel determines if additional scopes are created on top of the provider

However, creating a new scope on top of a per-scenario provider doesn't make semantic sense, as each scenario already has its own provider. Consider either documenting this behavior clearly or adding validation to prevent nonsensical configurations like ServiceProviderLifetime=Scenario with ScopeLevel=Feature.

        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>();

            if (spContainer.Scoping == ScopeLevelType.Scenario)
            {
                var serviceProvider = spContainer.ServiceProvider;
                // Now we can register a new scoped service provider
                args.ObjectContainer.RegisterFactoryAs<IServiceProvider>(() =>
                {
                    var scope = serviceProvider.CreateScope();
                    BindMappings.TryAdd(scope.ServiceProvider, args.ObjectContainer.Resolve<IContextManager>());
                    ActiveServiceScopes.TryAdd(args.ObjectContainer.Resolve<ScenarioContext>(), scope);
                    return scope.ServiceProvider;
                });
            }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


### 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
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 documentation states "it might make sense to have a new scope for each scenario rather than each feature (the default)" but according to the code, ScopeLevelType.Scenario is actually the default (as seen in ScenarioDependenciesAttribute.cs line 48). The documentation incorrectly implies that Feature is the default scope level.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines 44 to 47
```csharp
[ScenarioDependencies(ScopeLevel = ScopeLevelType.Scenario)]
public static IServiceCollection CreateServices()
```
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 code example is missing the closing brace and return statement for the CreateServices method. The example should include the complete method body to be valid compilable code.

Copilot uses AI. Check for mistakes.
Comment on lines +51 to +54
```csharp
[ScenarioDependencies(ServiceProviderLifetime = ServiceProviderLifetimeType.Feature)]
public static IServiceCollection CreateServices()
```
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 code example is missing the closing brace and return statement for the CreateServices method. The example should include the complete method body to be valid compilable code.

Copilot uses AI. Check for mistakes.
Comment on lines +45 to 50
private void PopulateCache()
{
if (_cache != default)
{
return _cache;
return;
}
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.
```

```{note}
If the `ServiceProviderLifetime` is set to `Scenario` then the `ScopeLevel` is implicitly `Scenario` as well.
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 documentation states that "If the ServiceProviderLifetime is set to Scenario then the ScopeLevel is implicitly Scenario as well," but there's no code implementation that enforces this constraint. The ScenarioDependenciesAttribute allows both properties to be set independently without any validation or automatic adjustment. Either the code should be updated to enforce this implicit relationship, or the documentation should be corrected to reflect the actual behavior.

Suggested change
If the `ServiceProviderLifetime` is set to `Scenario` then the `ScopeLevel` is implicitly `Scenario` as well.
`ServiceProviderLifetime` and `ScopeLevel` are configured independently. If you want both the service provider and the scope to be per-scenario, you must set both `ServiceProviderLifetime = ServiceProviderLifetimeType.Scenario` and `ScopeLevel = ScopeLevelType.Scenario` explicitly.

Copilot uses AI. Check for mistakes.
Comment on lines 163 to 174
[Fact]
public void GetServiceProviderLifetime_ReturnsCorrectLifetime()
{
// Arrange
var sut = CreateServiceCollectionFinderWithMocks(typeof(ValidStartWithFeatureLifetime));

// Act
var lifetime = sut.GetServiceProviderLifetime();

// Assert
lifetime.Should().Be(ServiceProviderLifetimeType.Feature);
}
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 GetServiceProviderLifetime method and the changes to ServiceCollectionFinder lack comprehensive test coverage. Tests only cover the Feature lifetime scenario, but there are no tests for TestThread, Scenario, or the default Global lifetime values. Consider adding test cases for these additional lifetime types to ensure the implementation works correctly for all supported scenarios.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

@AidenFuller AidenFuller Jan 14, 2026

Choose a reason for hiding this comment

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

Expanded tests to cover all states of the service provider lifetime

Comment on lines +68 to +77
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));
}
}
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.
… `ScopeLevel` with `ServiceProviderLifetime`. Improve test coverage and update documentation.
@gasparnagy gasparnagy added this to the v3.4 milestone Jan 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants