3

I've created a custom library which automatically sets up Polly policies for specific services which depend on HttpClient.

This is done using the IServiceCollection extension methods and the typed client approach. A simplified example:

public static IHttpClientBuilder SetUpFooServiceHttpClient(this IServiceCollection services)
{
    return services
            .AddHttpClient<FooService>()
            .AddPolicyHandler(GetRetryPolicy());
}

public class FooService
{
    private readonly HttpClient _client;

    public FooService(HttpClient httpClient)
    {
        _client = httpClient;
    }

    public void DoJob()
    {
         var test = _client.GetAsync("http://example.com");
    }
}

Note that my real code uses a generic type and an options builder, but I've omitted that part to keep it simple. The purpose of my tests is to confirm that my options builder correctly applies the policies that I want it to apply. For the sake of example here, let's just assume that it's a hardcoded retry policy which I want to test.

I now want to test if this library correctly registers the Polly policies to my injected HttpClient dependencies.

Note
There are many answers to be found online and on StackOverflow where the suggestion is to construct the HttpClient yourself, i.e.: new HttpClient(new MyMockedHandler());, but this defeats my purpose of needing to test if the actual IHttpClientFactory is constructing httpclients with the requested policies.

To that end, I want to test with a real HttpClient which was generated by a real IHttpClientFactory, but I want its handler to be mocked so I can avoid making actual web requests and artificially cause bad responses.

I'm using AddHttpMessageHandler() to inject a mocked handler, but the factory seems to be ignoring that.

Here's my test fixture:

public class BrokenDelegatingHandler : DelegatingHandler
{
    public int SendAsyncCount = 0;
    
    protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        SendAsyncCount++;
        
        return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
    }
}

private BrokenDelegatingHandler _brokenHandler = new BrokenDelegatingHandler();

private FooService GetService()
{
    var services = new ServiceCollection();

    services.AddTransient<BrokenDelegatingHandler>();
        
    var httpClientBuilder = services.SetUpFooServiceHttpClient();

    httpClientBuilder.AddHttpMessageHandler(() => _brokenHandler);
        
    services.AddSingleton<FooService>();
        
    return services
            .BuildServiceProvider()
            .GetRequiredService<FooService>();
}

And here's my test:

[Fact]
public void Retries_client_connection()
{
    int retryCount = 3;
    
    var service = GetService();

    _brokenHandler.SendAsyncCount.Should().Be(0); // PASS
    
    var result = service.DoJob();

    _brokenHandler.SendAsyncCount.Should().Be(retryCount); // FAIL: expected 3 but got 0
}

When I debug the test, the handler's breakpoint is never hit, and the response comes back as a 200 (because it actually connected to the URL, instead of hitting the mocked handler).

Why is my mocked handler being ignored by the http client factory?

Note that I will also accept any answer that allows me to test the policies in another valid way.

I'm aware I can just use a broken URL string but I'm going to need to test specific http responses in my tests.

Flater
  • 12,908
  • 4
  • 39
  • 62

1 Answers1

3

We had a similar problem few months ago. How to test that the injected HttpClient is decorated with the correct Policies. (We have used a Retry > CircuitBreaker > Timeout policy chain).

We ended up to create several integration tests. We have used WireMock.NET to create a server stub. So, the whole point of this was to let the ASP.NET DI do its magic and then scrutinize the stub's logs.

We have created two base classes which wrapped the WireMock setup (we had a POST endpoint).

FlawlessServer

internal abstract class FlawlessServiceMockBase
{
    protected readonly WireMockServer server;
    private readonly string route;

    protected FlawlessServiceMockBase(WireMockServer server, string route)
    {
        this.server = server;
        this.route = route;
    }

    public virtual void SetupMockForSuccessResponse(IResponseBuilder expectedResponse = null, 
        HttpStatusCode expectedStatusCode = HttpStatusCode.OK)
    {
        server.Reset();

        var endpointSetup = Request.Create().WithPath(route).UsingPost();
        var responseSetup = expectedResponse ?? Response.Create().WithStatusCode(expectedStatusCode);

        server.Given(endpointSetup).RespondWith(responseSetup);
    }
}

FaultyServer

(We have used scenarios to simulate timeouts)

internal abstract class FaultyServiceMockBase
{
    protected readonly WireMockServer server;
    protected readonly IRequestBuilder endpointSetup;
    protected readonly string scenario;

    protected FaultyServiceMockBase(WireMockServer server, string route)
    {
        this.server = server;
        this.endpointSetup = Request.Create().WithPath(route).UsingPost();
        this.scenario = $"polly-setup-test_{this.GetType().Name}";
    }

    public virtual void SetupMockForFailedResponse(IResponseBuilder expectedResponse = null,
        HttpStatusCode expectedStatusCode = HttpStatusCode.InternalServerError)
    {
        server.Reset();

        var responseSetup = expectedResponse ?? Response.Create().WithStatusCode(expectedStatusCode);

        server.Given(endpointSetup).RespondWith(responseSetup);
    }

    public virtual void SetupMockForSlowResponse(ResilienceSettings settings, string expectedResponse = null)
    {
        server.Reset();

        int higherDelayThanTimeout = settings.HttpRequestTimeoutInMilliseconds + 500;

        server
            .Given(endpointSetup)
            .InScenario(scenario)
            //NOTE: There is no WhenStateIs
            .WillSetStateTo(1)
            .WithTitle(Common.Constants.Stages.Begin)
            .RespondWith(DelayResponse(higherDelayThanTimeout, expectedResponse));

        for (var i = 1; i < settings.HttpRequestRetryCount; i++)
        {
            server
                .Given(endpointSetup)
                .InScenario(scenario)
                .WhenStateIs(i)
                .WillSetStateTo(i + 1)
                .WithTitle($"{Common.Constants.Stages.RetryAttempt} #{i}")
                .RespondWith(DelayResponse(higherDelayThanTimeout, expectedResponse));
        }

        server
            .Given(endpointSetup)
            .InScenario(scenario)
            .WhenStateIs(settings.HttpRequestRetryCount)
            //NOTE: There is no WillSetStateTo
            .WithTitle(Common.Constants.Stages.End)
            .RespondWith(DelayResponse(1, expectedResponse));
    }

    private static IResponseBuilder DelayResponse(int delay) => Response.Create()
        .WithDelay(delay)
        .WithStatusCode(200);

    private static IResponseBuilder DelayResponse(int delay, string response) => 
        response == null 
            ? DelayResponse(delay) 
            : DelayResponse(delay).WithBody(response);
}

Simple test for Slow processing

(proxyApiInitializer is a instance of a WebApplicationFactory<Startup> derived class)

[Fact]
public async Task GivenAValidInout_AndAServiceWithSlowProcessing_WhenICallXYZ_ThenItCallsTheServiceSeveralTimes_AndFinallySucceed()
{
    //Arrange - Proxy request
    HttpClient proxyApiClient = proxyApiInitializer.CreateClient();
    var input = new ValidInput();

    //Arrange - Service
    var xyzSvc = new FaultyXYZServiceMock(xyzServer.Value);
    xyzSvc.SetupMockForSlowResponse(resilienceSettings);

    //Act
    var actualResult = await CallXYZAsync(proxyApiClient, input);

    //Assert - Response
    const HttpStatusCode expectedStatusCode = HttpStatusCode.OK;
    actualResult.StatusCode.ShouldBe(expectedStatusCode);

    //Assert - Resilience Policy
    var logsEntries = xyzServer.Value.FindLogEntries(
        Request.Create().WithPath(Common.Constants.Routes.XYZService).UsingPost());
    logsEntries.Last().MappingTitle.ShouldBe(Common.Constants.Stages.End);
}

XYZ Server init:

private static Lazy<WireMockServer> xyzServer;

public ctor()
{
   xyzServer = xyzServer ?? InitMockServer(API.Constants.EndpointConstants.XYZServiceApi);
}

private Lazy<WireMockServer> InitMockServer(string lookupKey)
{
    string baseUrl = proxyApiInitializer.Configuration.GetValue<string>(lookupKey);
    return new Lazy<WireMockServer>(
        WireMockServer.Start(new FluentMockServerSettings { Urls = new[] { baseUrl } }));
}

I hope this can help you.

Peter Csala
  • 17,736
  • 16
  • 35
  • 75
  • +1 For what seems to be a valid solution to the problem, but to be honest I'm a bit apprehensive of taking this route as it's a quite extensive implementation of an actual server process for what essentially needs to be a mocked handler with a hardcoded response. – Flater Jul 23 '20 at 11:17
  • @Flater I agree with you that this is quite heavy. I would be happy to see some sort of component level testing that can verify the policies. Unfortunately if we could somehow retrieve the policies we could not assess their properties. We can't be sure that the policies were setup in the way as expected. I've already raised an [issue](https://github.com/App-vNext/Polly/issues/756) on Polly to address this deficit. V8 will be released with a redesigned configuration. – Peter Csala Jul 23 '20 at 11:58
  • 1
    Based on my findings, `AddHttpMessageHandler` should be able to solve that issue as it would add an inner handler to the polly handlers, thus allowing you to have a mocked outcome that gets handled by the _real_ polly handlers. MSDN confirms that `AddHttpMessageHandler` should work the way I expect it to, yet the DI container seems to completely ignore the fact that I've called it. – Flater Jul 23 '20 at 12:04
  • 1
    I've been able to narrow down the problem. An injected `IHttpClientFactory` respects my custom handlers (including Polly), but an injected `HttpClient` doesn't (it also ignores Polly policies). That seemingly contradicts documentation I've found on the subject but I'll explore that in a different question. I'm giving you the tick as your answer is a valid solution, but I'm not going to use it because it is a bit heavy for my use case. – Flater Jul 23 '20 at 12:47
  • @Flater Thanks and waiting for that different question :D – Peter Csala Jul 23 '20 at 12:58
  • 1
    [The new question](https://stackoverflow.com/questions/63056470/injected-httpclient-ignores-ihttpclientfactory-configuration), for reference :) – Flater Jul 23 '20 at 14:20