为 IHttpClientFactory 和类型化 HttpClient 模拟 IHttpMessageHandler 时出现问题

Problem mocking IHttpMessageHandler for both IHttpClientFactory and typed HttpClient

我想测试一些 class 收到 IHttpClientFactory 的结果。然后他们使用那个工厂在里面创建一个HttpClient

public class SampleClassUsingHttpClientFactory
{
    private readonly IHttpClientFactory _httpClientFactory;

    public SampleClassUsingHttpClientFactory(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    public async Task Run()
    {
        ...
        var client = _httpClientFactory.CreateClient();
        await client.SendAsync(request);
        ...
    }
}

为了测试这个 class 我模拟 HttpClientFactory 总是 return 和 HttpMessageHandler 模拟的 HttpClient:

public class WebApplicationFactoryWithMockedFactory : WebApplicationFactory<Startup>
{
    public readonly Mock<HttpMessageHandler> HttpMessageHandlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);

    public readonly Mock<IHttpClientFactory> HttpClientFactoryMock = new Mock<IHttpClientFactory>();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.AddScoped(srv => HttpMessageHandlerMock.Object);
            services.AddScoped(srv => HttpClientFactoryMock.Object);
        });
    }

    public void ConfigureHttpClientFactoryForMockedHttpClient(string url)
    {
        HttpClientFactoryMock
            .Setup(x => x.CreateClient(It.IsAny<string>()))
            .Returns(new HttpClient(HttpMessageHandlerMock.Object)
            {
                BaseAddress = new Uri(url)
            });
    }

然后我可以设置 HttpMessageHandlerMock 以按照我需要的方式运行测试。一切正常。

另一边,我想测试的其他 classes 直接收到一个类型化的 HttpClient。例如:

public class SampleClassUsingTypedHttpClient : ISampleClassUsingTypedHttpClient
{
    private readonly HttpClient _httpClient;

    public SampleClassUsingTypedHttpClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task Execute()
    {
        ...
        await _httpClient.SendAsync(request);
        ...
    }
}

注入正确的HttpClient是因为在ServiceCollection中有如下注册:

services.AddHttpClient<ISampleClassUsingTypedHttpClient, SampleClassUsingTypedHttpClient>()
    .ConfigureHttpClient((serviceProvider, httpClient) =>
    {
        httpClient.BaseAddress = new Uri("http://this.is.a.sample");
    });

为了测试 SampleClassUsingTypedHttpClient 我注册了类型化的 HttpMessageHandler 强制使用 HttpMessageHandlerMock 作为消息处理程序:

public class WebApplicationFactoryWithTypedHttpClient : WebApplicationFactory<Startup>
{
    public readonly Mock<HttpMessageHandler> HttpMessageHandlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);
    
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.AddHttpClient<ISampleClassUsingTypedHttpClient, SampleClassUsingTypedHttpClient>()
                .ConfigureHttpClient(x => x.BaseAddress = new Uri("http://this.is.a.sample.uri.for.tests"))
                .ConfigurePrimaryHttpMessageHandler(() => HttpMessageHandlerMock.Object);
        });
    }
}

然后我可以设置 HttpMessageHandlerMock 以按照我需要的方式运行,一切正常。

但是有些测试需要同时注册两个模拟(HttpClientFactory 和类型 HttpClient):

public class WebApplicationFactoryWithMockedFactoryAndTypedHttpClient : WebApplicationFactory<Startup>
{
    public readonly Mock<HttpMessageHandler> HttpMessageHandlerMock = new Mock<HttpMessageHandler>(MockBehavior.Strict);

    public readonly Mock<IHttpClientFactory> HttpClientFactoryMock = new Mock<IHttpClientFactory>();

    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureTestServices(services =>
        {
            services.AddScoped(srv => HttpMessageHandlerMock.Object);
            services.AddScoped(srv => HttpClientFactoryMock.Object);
            
            services.AddHttpClient<ISampleClassUsingTypedHttpClient, SampleClassUsingTypedHttpClient>()
                .ConfigureHttpClient(x => x.BaseAddress = new Uri("http://this.is.a.sample.uri.for.tests"))
                .ConfigurePrimaryHttpMessageHandler(() => HttpMessageHandlerMock.Object);           
        });
    }
    
    public void ConfigureHttpClientFactoryForMockedHttpClient(string url)
    {
        HttpClientFactoryMock
            .Setup(x => x.CreateClient(It.IsAny<string>()))
            .Returns(new HttpClient(HttpMessageHandlerMock.Object)
            {
                BaseAddress = new Uri(url)
            });
    }
}

执行测试时,在依赖注入期间抛出 NullReferenceException。从堆栈跟踪看来,.NET 内部使用的 DefaultTypedHttpClientFactory 无法创建 HttpClient 的实例。也许它正在调用模拟 HttpClientFactory 但模拟 returns null,但它不应该,因为它是通过调用 ConfigureHttpClientFactoryForMockedHttpClient 设置的。这是堆栈跟踪的片段:

   at Microsoft.Extensions.Http.DefaultTypedHttpClientFactory`1.CreateClient(HttpClient httpClient)
   at Microsoft.Extensions.DependencyInjection.HttpClientBuilderExtensions.<>c__DisplayClass12_0`2.<AddTypedClientCore>b__0(IServiceProvider s)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitFactory(FactoryCallSite factoryCallSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitDisposeCache(ServiceCallSite transientCallSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitConstructor(ConstructorCallSite constructorCallSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSiteMain(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.VisitDisposeCache(ServiceCallSite transientCallSite, RuntimeResolverContext context)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteVisitor`2.VisitCallSite(ServiceCallSite callSite, TArgument argument)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteRuntimeResolver.Resolve(ServiceCallSite callSite, ServiceProviderEngineScope scope)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.DynamicServiceProviderEngine.<>c__DisplayClass1_0.<RealizeService>b__0(ServiceProviderEngineScope scope)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.GetService(Type serviceType, ServiceProviderEngineScope serviceProviderEngineScope)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.GetService(Type serviceType)
   at Microsoft.Extensions.DependencyInjection.ActivatorUtilities.GetService(IServiceProvider sp, Type type, Type requiredBy, Boolean isDefaultParameterRequired)
   at Microsoft.AspNetCore.Mvc.Controllers.ControllerActivatorProvider.<>c__DisplayClass4_0.<CreateActivator>b__0(ControllerContext controllerContext)
   at Microsoft.AspNetCore.Mvc.Controllers.ControllerFactoryProvider.<>c__DisplayClass5_0.<CreateControllerFactory>g__CreateController|0(ControllerContext controllerContext)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.InvokeInnerFilterAsync()
   ...

有什么办法可以解决这个问题吗?

我在我的代码中发现了问题。当你像我一样模拟 IHttpClientFactory 时,每个 HttpClient 都是使用那个模拟工厂创建的,所以这一行什么都不做:

services.AddHttpClient<ISampleClassUsingTypedHttpClient, SampleClassUsingTypedHttpClient>()
    .ConfigureHttpClient(x => x.BaseAddress = new Uri("http://this.is.a.sample.uri.for.tests"))
    .ConfigurePrimaryHttpMessageHandler(() => HttpMessageHandlerMock.Object);

解决方案是将 IHttpClientFactory 的模拟设置为 return,同时为类型化的客户端请求设置一个模拟的 HttpClient。可以通过调用这样的方法来完成:

public void ConfigureHttpClientFactoryMockForMockedTypedHttpClient<T>(Uri baseAddress)
{
    string httpClientName = typeof(T).Name;

    HttpClientFactoryMock
        .Setup(x => x.CreateClient(It.Is<string>(x => x.Equals(httpClientName, StringComparison.InvariantCultureIgnoreCase))))
        .Returns(new HttpClient(HttpMessageHandlerMock.Object)
        {
            BaseAddress = baseAddress
        });
}