Photo by Caspar Camille Rubin

Introduction

When you register your custom HttpClient using the AddHttpClient method, it's also quite common that you configure some basic settings like BaseAddress and some default request headers. Something like this:

public static IServiceCollection AddMyHttpClient(this IServiceCollection services)
{
    services.AddHttpClient<MyHttpClient>((provider, client) =>
    {
        var configuration = provider.GetRequiredService<IConfiguration>();
        client.BaseAddress = new Uri("https://api.local.localhost");
        client.Timeout = TimeSpan.FromSeconds(5);
        var username = configuration.GetRequiredValue<string>("MyHttpClient:Username");
        var password = configuration.GetRequiredValue<string>("MyHttpClient:Password");
        var authorizationHeaderValue = $"{username}:{password}";
        var base64Encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(authorizationHeaderValue));
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", base64Encoded);
    });
    return services;
}

Here we are configuring the following (for all requests using this HttpClient):

  • The BaseAddress (https://api.local.localhost)
  • A timeout (5 seconds)
  • A basic authorization header using credentials from IConfiguration.

Nothing weird going on here, infact, this is a common pattern I've seen a bunch of times. However, it's far less common to see any tests covering this code...

Let's see how we can test this code.

Testing

Overview

The MyHttpClient looks like this:

public class MyHttpClient
{
    private readonly HttpClient _httpClient;

    public MyHttpClient(HttpClient httpClient)
    {
        _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
    }

    public async Task<HttpStatusCode> HealthCheck()
    {
        var request = new HttpRequestMessage(HttpMethod.Get, "/health");
        using var response = await _httpClient.SendAsync(request);
        return response.StatusCode;
    }
}

The AddHttpClient configures the HttpClient that gets injected in the MyHttpClient. The injected HttpClient is stored as a private field in the MyHttpClient class.

From our tests, we need to find a way to access the HttpClient field from our MyHttpClient instance, how can we do that?

Just change the field from private to public!!

Sure, that would solve our problem but then we are changing our code just to make it easier to write our tests. Now, that's usually a good thing, we should write code that's easy to test, absolutely. But on the other hand, we don't want to leak implementation details just for the sake of it. When consuming a MyHttpClient, should the consumer also have access to the actual HttpClient instance? I'd say no.

Is there another way?

Yes! By using reflection, of course :)

private static HttpClient GetHttpClientField(MyHttpClient myHttpClient)
{
    return myHttpClient
           .GetType()
           .GetFields(BindingFlags.Instance | BindingFlags.GetField | BindingFlags.NonPublic)
           .FirstOrDefault(x => x.FieldType == typeof(HttpClient))
           ?.GetValue(myHttpClient) as HttpClient
           ?? throw new Exception("Failed to find a HttpClient field!");
}
  • Get all fields on the MyHttpClient instance
  • Select the first field that's a HttpClient
  • Throw if not found

But...our MyHttpClient requires a HttpClient, how do you create the MyHttpClient instance?

Remember that what we actually want to test is the code that we've written in the AddHttpClient method?
That code is an extension method on the IServiceCollection interface.
So, we can simply just new up a ServiceCollection and start populating it and then resolve the MyHttpClient from the service provider.

BaseAddress test

Here's a test that verifies that we've set the BaseAddress correctly.

[Fact]
public void MyHttpClient_ShouldSetBaseBaseAddressWhenRegistering()
{
    var configuration = CreateConfiguration(new List<KeyValuePair<string, string>>
    {
        new("MyHttpClient:Username", "any-username"),
        new("MyHttpClient:Password", "any-password")
    });
    var services = new ServiceCollection();
    services.AddSingleton<IConfiguration>(configuration);

    services.AddMyHttpClient();

    var serviceProvider = services.BuildServiceProvider();
    var myHttpClient = serviceProvider.GetRequiredService<MyHttpClient>();
    var httpClient = GetHttpClientField(myHttpClient);
    httpClient.ShouldNotBeNull();
    httpClient.ShouldBeOfType<HttpClient>();
    httpClient.BaseAddress.ShouldBe(new Uri("https://api.local.localhost"));
}

private static ConfigurationRoot CreateConfiguration(
    List<KeyValuePair<string, string>> configurationValues)
{
    return new ConfigurationRoot(new List<IConfigurationProvider>
    {
        new MemoryConfigurationProvider(
            new MemoryConfigurationSource
            {
                InitialData = configurationValues
            })
    });
}

private static HttpClient GetHttpClientField(MyHttpClient myHttpClient)
{
    return myHttpClient
           .GetType()
           .GetFields(BindingFlags.Instance | BindingFlags.GetField | BindingFlags.NonPublic)
           .FirstOrDefault(x => x.FieldType == typeof(HttpClient))
           ?.GetValue(myHttpClient) as HttpClient
           ?? throw new Exception("Failed to find a HttpClient field!");
}
  1. First we setup our configuration
  2. We then call the AddMyHttpClient extension method
  3. We then create a ServiceProvider from our ServiceCollection
  4. We then retrieve a MyHttpClient instance from the ServiceProvider
  5. We then retrieve the actual HttpClient by using reflection (GetHttpClientField)
  6. We then assert on the retrieved HttpClient

We can then add a couple of other tests, like what happens if we are missing username and/or password in our configuration?

[Fact]
public void MyHttpClient_ShouldThrowExceptionIfMyHttpClientUsernameConfigurationIsMissing()
{
    var configuration = CreateConfiguration(new List<KeyValuePair<string, string>>
    {
        new("MyHttpClient:Password", "any-password")
    });
    var services = new ServiceCollection();
    services.AddSingleton<IConfiguration>(configuration);

    services.AddMyHttpClient();

    var serviceProvider = services.BuildServiceProvider();
    var exception = Should.Throw<Exception>(() => serviceProvider.GetRequiredService<MyHttpClient>());
    exception.Message.ShouldBe("'MyHttpClient:Username' had no value, have you forgot to add it to the Configuration?");
}

[Fact]
public void MyHttpClient_ShouldThrowExceptionIfMyHttpClientPasswordConfigurationIsMissing()
{
    var configuration = CreateConfiguration(new List<KeyValuePair<string, string>>
    {
        new("MyHttpClient:Username", "any-username")
    });
    var services = new ServiceCollection();
    services.AddSingleton<IConfiguration>(configuration);

    services.AddMyHttpClient();

    var serviceProvider = services.BuildServiceProvider();
    var exception = Should.Throw<Exception>(() => serviceProvider.GetRequiredService<MyHttpClient>());
    exception.Message.ShouldBe("'MyHttpClient:Password' had no value, have you forgot to add it to the Configuration?");
}

Timeout test

Here we are testing that the timeout is set to 5 seconds.

[Fact]
public void MyHttpClient_ShouldSetTimeoutTo5Seconds()
{
    var username = "any-username";
    var password = "any-password";
    var configuration = CreateConfiguration(new List<KeyValuePair<string, string>>
    {
        new("MyHttpClient:Username", username),
        new("MyHttpClient:Password", password)
    });
    var services = new ServiceCollection();
    services.AddSingleton<IConfiguration>(configuration);
    var expectedAuthorizationHeaderValue =
        Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));

    services.AddMyHttpClient();

    var serviceProvider = services.BuildServiceProvider();
    var myHttpClient = serviceProvider.GetRequiredService<MyHttpClient>();
    var httpClient = GetHttpClientField(myHttpClient);
    httpClient.ShouldNotBeNull();
    httpClient.Timeout.ShouldBe(TimeSpan.FromSeconds(5));
}

Basic Authorization header test

Here we are testing that we are adding the authorization header correctly

[Fact]
public void MyHttpClient_ShouldAddBase64EncodedBasicAuthorizationHeader()
{
    var username = "any-username";
    var password = "any-password";
    var configuration = CreateConfiguration(new List<KeyValuePair<string, string>>
    {
        new("MyHttpClient:Username", username),
        new("MyHttpClient:Password", password)
    });
    var services = new ServiceCollection();
    services.AddSingleton<IConfiguration>(configuration);
    var expectedAuthorizationHeaderValue =
        Convert.ToBase64String(Encoding.UTF8.GetBytes($"{username}:{password}"));

    services.AddMyHttpClient();

    var serviceProvider = services.BuildServiceProvider();
    var myHttpClient = serviceProvider.GetRequiredService<MyHttpClient>();
    var httpClient = GetHttpClientField(myHttpClient);
    httpClient.ShouldNotBeNull();
    httpClient.DefaultRequestHeaders.Authorization.ShouldNotBeNull();
    httpClient.DefaultRequestHeaders.Authorization.Scheme.ShouldBe("Basic");
    httpClient.DefaultRequestHeaders.Authorization.Parameter.ShouldBe(expectedAuthorizationHeaderValue);
}

All code in this post can be found at GitHub.