使用 Polly 对 HttpClient 进行单元测试

Unit test HttpClient with Polly

我正在寻找对具有 Polly RetryPolicyHttpClient 进行单元测试,并且我正在尝试找出如何控制 HTTP 响应的内容会的。

我在客户端上使用了 HttpMessageHandler,然后覆盖发送异步,这很好用,但是当我添加 Polly 重试策略时,我必须使用 [=16= 创建一个 HTTP 客户端实例] 并且无法为客户端创建 HttpMessageHandler。我试过使用 .AddHttpMessageHandler() 但这会阻止轮询重试策略并且它只会触发一次。

这就是我在测试中设置 HTTP 客户端的方式

IServiceCollection services = new ServiceCollection();

const string TestClient = "TestClient";
 
services.AddHttpClient(name: TestClient)
         .AddHttpMessageHandler()
         .SetHandlerLifetime(TimeSpan.FromMinutes(5))
         .AddPolicyHandler(KYA_GroupService.ProductMessage.ProductMessageHandler.GetRetryPolicy());

HttpClient configuredClient =
                services
                    .BuildServiceProvider()
                    .GetRequiredService<IHttpClientFactory>()
                    .CreateClient(TestClient);

public static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
    return HttpPolicyExtensions
            .HandleTransientHttpError()
            .WaitAndRetryAsync(6,
                retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
                onRetryAsync: OnRetryAsync);
}

private async static Task OnRetryAsync(DelegateResult<HttpResponseMessage> outcome, TimeSpan timespan, int retryCount, Context context)
{
    //Log result
}

这将在我调用 _httpClient.SendAsync(httpRequestMessage) 时触发请求,但它实际上创建了一个 Http 调用地址,我需要以某种方式拦截它并 return 一个受控的响应。

我想测试该策略是否用于在请求失败时重试请求并在完整响应时完成。

我的主要限制是我不能在 MSTest 上使用 Moq。

您不希望 HttpClient 发出真正的 HTTP 请求作为单元测试的一部分 - 那将是一个集成测试。为避免发出真正的请求,您需要提供自定义 HttpMessageHandler。您已在 post 中规定您不想使用模拟框架,因此您可以提供 stub.

而不是模拟 HttpMessageHandler

由于 this 对 Polly 的 GitHub 页面上某个问题的评论产生了重大影响,我调整了您的示例以调用存根 HttpMessageHandler,它在第一次出现时抛出 500调用,然后 returns 在后续请求中调用 200。

测试断言调用了重试处理程序,并且当执行步骤超过对 HttpClient.SendAsync 的调用时,结果响应的状态代码为 200:

public class HttpClient_Polly_Test
{
    const string TestClient = "TestClient";
    private bool _isRetryCalled;

    [Fact]
    public async Task Given_A_Retry_Policy_Has_Been_Registered_For_A_HttpClient_When_The_HttpRequest_Fails_Then_The_Request_Is_Retried()
    {
        // Arrange 
        IServiceCollection services = new ServiceCollection();
        _isRetryCalled = false;

        services.AddHttpClient(TestClient)
            .AddPolicyHandler(GetRetryPolicy())
            .AddHttpMessageHandler(() => new StubDelegatingHandler());

        HttpClient configuredClient =
            services
                .BuildServiceProvider()
                .GetRequiredService<IHttpClientFactory>()
                .CreateClient(TestClient);

        // Act
        var result = await configuredClient.GetAsync("https://www.whosebug.com");

        // Assert
        Assert.True(_isRetryCalled);
        Assert.Equal(HttpStatusCode.OK, result.StatusCode);
    }

    public IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
    {
        return HttpPolicyExtensions.HandleTransientHttpError()
            .WaitAndRetryAsync(
                6,
                retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
                onRetryAsync: OnRetryAsync);
    }

    private async Task OnRetryAsync(DelegateResult<HttpResponseMessage> outcome, TimeSpan timespan, int retryCount, Context context)
    {
        //Log result
        _isRetryCalled = true;
    }
}

public class StubDelegatingHandler : DelegatingHandler
{
    private int _count = 0;

    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        if (_count == 0)
        {
            _count++;
            return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
        }

        return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
    }
}

上面的回答对我走上正轨很有帮助。但是我想测试策略是否已添加到类型化的 http 客户端。该客户端在应用程序启动时定义。因此,挑战在于如何在类型化客户端定义中指定的处理程序之后添加存根委托处理程序,并将其添加到服务集合中。

我能够利用 IHttpMessageHandlerBuilderFilter.Configure 并将存根处理程序添加为链中的最后一个处理程序。

public sealed class HttpClientInterceptionFilter : IHttpMessageHandlerBuilderFilter
{
    HandlerConfig handlerconfig { get; set; }

    public HttpClientInterceptionFilter(HandlerConfig calls)
    {
        handlerconfig = calls;
    }
    /// <inheritdoc/>
    public Action<HttpMessageHandlerBuilder> Configure(Action<HttpMessageHandlerBuilder> next)
    {
        return (builder) =>
        {
            // Run any actions the application has configured for itself
            next(builder);

            // Add the interceptor as the last message handler
            builder.AdditionalHandlers.Add(new StubDelegatingHandler(handlerconfig));
        };
    }
}

在您的单元测试中使用 DI 容器注册此 class:

services.AddTransient<IHttpMessageHandlerBuilderFilter>(n => new HttpClientInterceptionFilter(handlerConfig));

我需要将一些参数传递给存根处理程序并从中获取数据并返回到我的单元测试。我用这个 class 这样做:

public class HandlerConfig
{
    public int CallCount { get; set; }
    public DateTime[] CallTimes { get; set; }
    public int BackOffSeconds { get; set; }
    public ErrorTypeEnum ErrorType { get; set; }
}

public enum ErrorTypeEnum
{
    Transient,
    TooManyRequests
}

我的存根处理程序生成瞬态和过多的请求响应:

public class StubDelegatingHandler : DelegatingHandler
{
    private HandlerConfig _config;
    HttpStatusCode[] TransientErrors = new HttpStatusCode[] { HttpStatusCode.RequestTimeout, HttpStatusCode.InternalServerError, HttpStatusCode.OK };

    public StubDelegatingHandler(HandlerConfig config)
    {
        _config = config;
    }
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        _config.CallTimes[_config.CallCount] = DateTime.Now;

        if (_config.ErrorType == ErrorTypeEnum.Transient)
        {              
            var response = new HttpResponseMessage(TransientErrors[_config.CallCount]);
            _config.CallCount++;
            return Task.FromResult(response);
        }

        HttpResponseMessage response429;
        if (_config.CallCount < 2)
        {
            //generate 429 errors
            response429 = new HttpResponseMessage(HttpStatusCode.TooManyRequests);
            response429.Headers.Date = DateTime.UtcNow;

            DateTimeOffset dateTimeOffSet = DateTimeOffset.UtcNow.Add(new TimeSpan(0, 0, 5));
            long resetDateTime = dateTimeOffSet.ToUnixTimeSeconds();
            response429.Headers.Add("x-rate-limit-reset", resetDateTime.ToString());
        }
        else
        {
            response429 = new HttpResponseMessage(HttpStatusCode.OK);
        }

        _config.CallCount++;

        return Task.FromResult(response429);

    }
}

最后是单元测试:

[TestMethod]
public async Task Given_A_429_Retry_Policy_Has_Been_Registered_For_A_HttpClient_When_429_Errors_Occur_Then_The_Request_Is_Retried()
    {
        // Arrange 
        IServiceCollection services = new ServiceCollection();

        var handlerConfig = new HandlerConfig { ErrorType = ErrorTypeEnum.TooManyRequests, BackOffSeconds = 5, CallTimes = new System.DateTime[RetryCount] };

        // this registers a stub message handler that returns the desired error codes
        services.AddTransient<IHttpMessageHandlerBuilderFilter>(n => new HttpClientInterceptionFilter(handlerConfig));

        services.ConfigureAPIClient();  //this is an extension method that adds a typed client to the services collection

        HttpClient configuredClient =
            services
                .BuildServiceProvider()
                .GetRequiredService<IHttpClientFactory>()
               .CreateClient("APIClient");  //Note this must be the same name used in ConfigureAPIClient

        //  Act
        var result = await configuredClient.GetAsync("https://localhost/test");

        //   Assert
        Assert.AreEqual(3, handlerConfig.CallCount, "Expected number of  calls made");
        Assert.AreEqual(HttpStatusCode.OK, result.StatusCode, "Verfiy status code");

        var actualWaitTime = handlerConfig.CallTimes[1] - handlerConfig.CallTimes[0];
        var expectedWaitTime = handlerConfig.BackOffSeconds + 1;  //ConfigureAPIClient adds one second to give a little buffer
        Assert.AreEqual(expectedWaitTime, actualWaitTime.Seconds);           
    }
}