reqnroll / Reqnroll

Open-source Cucumber-style BDD test automation framework for .NET.
https://reqnroll.net
BSD 3-Clause "New" or "Revised" License
391 stars 44 forks source link

Injecting ScenarioContext when overriding HttpClientHandler SendAsync breaks execution. #333

Open 13dante04 opened 1 week ago

13dante04 commented 1 week ago

Reqnroll Version

2.2.1

Which test runner are you using?

xUnit

Test Runner Version Number

2.8.2

.NET Implementation

.NET 8.0

Test Execution Method

ReSharper Test Runner

Content of reqnroll.json configuration file

No response

Issue Description

When trying to inject ScenarioContext in an HttpClient handler, when using Microsoft DI. This error occurs

Use case is, setting data in the scenario context, which is later on set as a request header.

Reqnroll.ReqnrollException Unable to access test execution dependent service 'Reqnroll.ScenarioContext' with the Reqnroll.Microsoft.Extensions.DependencyInjection plugin. This service is only available once test execution has been started and cannot be used in '[BeforeTestRun]' hook. `

In this specific scenario, I'm trying to get the scenario context when overriding the SendAsync method.

This happens during test execution, when sending an http request, not before test execution as the message says

There is a repro project but here's also the block where scenario context is being accessed.

public class CustomHttpMessageHandler(IServiceProvider serviceProvider) : HttpClientHandler
{
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var scenarioContext = serviceProvider.GetRequiredService<ScenarioContext>();
        if (scenarioContext != null)
        {
            var tenantId = scenarioContext.Get<Guid>("tenantId").ToString();
            request.Headers.Add("tenantid", tenantId);
        }

        return base.SendAsync(request, cancellationToken);
    }
}

Entire stack trace attached as a file. trace.txt

Steps to Reproduce

1) Use Reqnroll.Microsoft.Extensions.DependencyInjection; 2) Add an HttpClient with a custom HttpMessageHandler 3) Inject ScenarioContext in the HttpMessageHandler

4) Get error message

Link to Repro Project

https://github.com/13dante04/Reqnroll.MSDI

gasparnagy commented 1 week ago

@13dante04 Thank you for the repro project. It helped a lot to analyze the issue. Pretty complex.

In order to understand the problem, please let me summarize first how the Reqnroll MSDI plugin works (this is not the full behavior, but the core concept):

  1. The plugin calls the [ScenarioDependencies] method that creates an IServiceCollection. This is not a service provider yet, just a recipe to create a service provider.
  2. Then it builds a service provider from the service collection and stores it in the global context. This all happens before the first scenario execution at the same time as the before/after test run hooks execute. So this root service provider is singleton, there is only one of it.
  3. For each scenario execution, it calls the CreateScope method on the root service provider to create a sub-service provider for the execution of the scenario. So there is another service provider created.
  4. The dependency management is smart. If you have a registration in the [ScenarioDependencies] it is normally resolved from the sub-service provider. Also if you register things with services.AddTransient<SomeType>(sp => do something with sp), the provided sp is the sub-service provider.

Your problem seems to be rooted in how the AddHttpClient extension method and the returned IHttpClientBuilder works. Due to the internal behavior of this, it always operates on the root service provider and ignores any sub-service providers created by CreateScope. There is a related issue in the related repo: https://github.com/dotnet/extensions/issues/521

Basically the suggestion there is not to use a message handler that has a reference to any context-specific state, but pass any state required through the Properties collection of the http request. Something similar is mentioned in the MSDI docs as well.

For me the fundamental question is whether the sub-scoping our plugin does is really a good idea or we should rather build a service provider for each scenario execution based on the service collection. (Although with this registering shared, singleton objects would not be possible easily.) Your input is appreciated on this, because it seems we need to re-design the MSDI plugin anyway.

For you (I guess) the question is if we can have a workaround. I played with it and found two worarounds:

Workaround 1: Pass ScenarioContext to the message handler, via the properties of the request.

In your example, there is a SomeHttpClient class that wraps the functions of the HttpClient. This class can have a dependency to the ScenarioContext and in the methods (like in the PostAsync method in your example), it could build the HttpRequestMessage and set the ScenarioContext to the Properties (or Options).

The message handler can then get the ScenarioContext from the request and work with it.

Workaround 2: Create a new scenario-specific service provider to resolve http clients

Since you cannot use the AddHttpClient method on the root service collection, alternatively you can create a brand new service collection for each scenario and build an additional service provider to resolve the HttpClient needs. The only trick is that in the normal service provider that is built based on the [ScenarioDependencies] method, you need to "forward" these requests to the new service provider. Here is a code file (a replacement of the DependencyInjection.cs in your sample) that does this:

using System;
using System.Net.Http;
using Microsoft.Extensions.DependencyInjection;
using Reqnroll.BoDi;
using Reqnroll.Infrastructure;
using Reqnroll.Microsoft.Extensions.DependencyInjection;
using Reqnroll.MSDI.Clients;

namespace Reqnroll.MSDI;

[Binding]
public class DependencyInjection
{
    [ScenarioDependencies]
    public static IServiceCollection CreateContainer()
    {
        var services = new ServiceCollection();

        // this two registration is not needed
        //services.AddSingleton<ReqnrollOutputHelper>();
        //services.AddTransient<CustomHttpMessageHandler>();

        // moved to InitScenarioServiceProviderForHttpClient
        // services.AddHttpClient<SomeHttpClient>().ConfigurePrimaryHttpMessageHandler(sp => new CustomHttpMessageHandler(sp));

        // resolve SomeHttpClient from the scenarios specific service provider 
        services.AddTransient<SomeHttpClient>(sp => sp.GetRequiredService<IContextManager>().ScenarioContext.ScenarioContainer.Resolve<SomeHttpClient>());
        return services;
    }

    [BeforeScenario(Order = -1)]
    public void InitScenarioServiceProviderForHttpClient(IObjectContainer objectContainer)
    {
        var services = new ServiceCollection();
        // initialize Http Client handling
        services.AddHttpClient<SomeHttpClient>().ConfigurePrimaryHttpMessageHandler(sp => new CustomHttpMessageHandler(sp));

        // "forward" ScenarioContext request to the Reqnroll container
        services.AddTransient(_ => objectContainer.Resolve<ScenarioContext>());

        // make a service provider to handle the HttpClients within this scenario execution
        var serviceProvider = services.BuildServiceProvider();

        // allow resolving SomeHttpClient via the specific service provider
        objectContainer.RegisterFactoryAs(() => serviceProvider.GetRequiredService<SomeHttpClient>());
    }
}