Search code examples
c#unit-testingdependency-injectioninversion-of-control

How to unit test/dependency inject a class reliant on HttpClient with a custom HttpClientHandler configuration


I'm looking for suggestions on how to improve on my current design for testing a class (example below) that depends on HttpClient with a custom HttpClientHandler configuration. I normally use constructor injection to inject a HttpClient that is consistent across the application, however because this is in a class library I can't rely on the consumers of the library to set up the HttpClientHandler correctly.

For testing I follow the standard approach of replacing HttpClientHandler in the HttpClient constructor. Because I can't rely on the consumer of the library to inject a valid HttpClient I'm not putting this in a public constructor, instead I'm using a private constructor with an internal static method (CreateWithCustomHttpClient()) to create it. The intent behind this is:

  • Private constructor should not be called by a dependency injection library automatically. I'm aware that if I made it public/internal then some DI libraries that had a HttpClient already registered would call that constructor.
  • Internal static method can be called by a unit testing library using InternalsVisibleToAttribute

This setup seems quite complex to me and I'm hoping someone might be able to suggest an improvement, I am however aware that this could be quite subjective so if there are any established patterns or design rules to follow in this case I would really appreciate hearing about them.

I've included the DownloadSomethingAsync() method just to demonstrate why the non-standard configuration is required for HttpClientHandler. The default is for redirect responses to automatically redirect internally without returning the response, I need the redirect response so that I can wrap it in a class that report progress on the download (the functionality of that is not relevant to this question).

public class DemoClass
{
    private static readonly HttpClient defaultHttpClient = new HttpClient(
            new HttpClientHandler
            {
                AllowAutoRedirect = false
            });

    private readonly ILogger<DemoClass> logger;
    private readonly HttpClient httpClient;

    public DemoClass(ILogger<DemoClass> logger) : this(logger, defaultHttpClient) { }

    private DemoClass(ILogger<DemoClass> logger, HttpClient httpClient)
    {
        this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
        this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
    }

    [Obsolete("This is only provided for testing and should not be used in calling code")]
    internal static DemoClass CreateWithCustomHttpClient(ILogger<DemoClass> logger, HttpClient httpClient)
        => new DemoClass(logger, httpClient);

    public async Task<FileSystemInfo> DownloadSomethingAsync(CancellationToken ct = default)
    {
        // Build the request
        logger.LogInformation("Sending request for download");
        HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, "http://example.com/downloadredirect");

        // Send the request
        HttpResponseMessage response = await httpClient.SendAsync(request, ct);

        // Analyse the result
        switch (response.StatusCode)
        {
            case HttpStatusCode.Redirect:
                break;
            case HttpStatusCode.NoContent:
                return null;
            default: throw new InvalidOperationException();
        }

        // Get the redirect location
        Uri redirect = response.Headers.Location;

        if (redirect == null)
            throw new InvalidOperationException("Redirect response did not contain a redirect URI");

        // Create a class to handle the download with progress tracking
        logger.LogDebug("Wrapping release download request");
        IDownloadController controller = new HttpDownloadController(redirect);

        // Begin the download
        logger.LogDebug("Beginning release download");

        return await controller.DownloadAsync();
    }
}

Solution

  • In my opinion, I'd use IHttpClientFactory in Microsoft.Extensions.Http, and create a custom dependency injection extension for consumers of the class library to use:

    public static class DemoClassServiceCollectionExtensions
    {
        public static IServiceCollection AddDemoClass(
            this IServiceCollection services, 
            Func<HttpMessageHandler> configureHandler = null)
        {
            // Configure named HTTP client with primary message handler
            var builder= services.AddHttpClient(nameof(DemoClass));
    
            if (configureHandler == null)
            {
                builder = builder.ConfigurePrimaryHttpMessageHandler(
                    () => new HttpClientHandler
                    {
                        AllowAutoRedirect = false
                    });
            }
            else
            {
                builder = builder.ConfigurePrimaryHttpMessageHandler(configureHandler);
            }
    
            services.AddTransient<DemoClass>();
    
            return services;
        }
    }
    

    In DemoClass, use IHttpClientFactory to create named HTTP client:

    class DemoClass
    {
        private readonly HttpClient _client;
    
        public DemoClass(IHttpClientFactory httpClientFactory)
        {
            // This named client will have pre-configured message handler
            _client = httpClientFactory.CreateClient(nameof(DemoClass));
        }
    
        public async Task DownloadSomethingAsync()
        {
            // omitted
        }
    }
    

    You could require consumers to must call AddDemoClass in order to use DemoClass:

    var services = new ServiceCollection();
    services.AddDemoClass();
    

    In this way, you could hide details of HTTP client construction.

    Meanwhile, in tests, you could mock IHttpClientFactory to return HttpClient for testing purpose.