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:
HttpClient
already registered would call that constructor.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();
}
}
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.