Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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();
    }
}
like image 883
Brad Avatar asked Dec 02 '25 23:12

Brad


1 Answers

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.

like image 50
weichch Avatar answered Dec 04 '25 14:12

weichch



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!