Quick: BookEnd for HttpClient

At a GREAT [Seattle Software Craftsmanshi meetup where David Bernstein gave a talk, there was a question at the end that really piqued my interest.

The question was, paraphrased:

How can we get tests for something using the HttpClient?

This is a C# specific question - and ... well.... I happen to have AN answer for that. I'm sure there are a lot of ways to accomplish this, but here's where I've found success.

BookEnd It!

As I've talked about a few places - Abstract code you don't own!
Where code meets Operating System
Where our code meets their code
Where our code meets the user

We don't own the HttpClient and it doesn't even have an interface!!!

What I do is create a wrapper around this object - a BookEnd that I control.

internal class HttpClientBookEnd : IHttpClientBookEnd{
    public readonly HttpClient _httpClient;
    public HttpClientBookEnd() : this(new HttpClient()){}
    private HttpClientBookEnd(HttpClient httpClient) => _httpClient = httpClient;
    public void SendRequestAsync(HttpRequestMessage msg) => _httpClient.SendRequestAsync(msg);
}

This is then used by a consuming class as

class MakesCall : IMakesCall{
    private readonly IHttpClientBookEnd _client;
    public MakesCall() : this(new HttpClientBookEnd()){}
    public MakesCall(IHttpClientBookEnd client) => _client = client;
    public void Call(HttpRequestMessage msg) => _client.SendRequestAsync(msg);
}

Note: HttpRequestMessage, as a class we don't control, should also have a BookEnd, but that's beyond the scope of what we're talking about here. How we're creating a BookEnd here will work pretty well as a drop in replacement, enabling much better testability.

With the HttpClient removed from our code, and is instead using an interface we can unit test the MakesCall class.

Code Integration Tests?

The follow up to this change is how to do code level integration tests? It'll still new up the HttpClient and hit the network.

This requires a bit of a hack. Or a polite term - a SHIM. Shim it!
The shim violates most of my coding practices. There's one practice that really stands out - Logic MUST be testable.

When we use 3rd party code, it can be hard to test the logic in the same class/method. This is unacceptable. It must be testable, and it must be testable in a fashion that has no dependencies.

HttpClientBookEnd is untestable though... yes. It's also got ZERO logic. BookEnds are pass throughs. They shouldn't exist according to the rest of my principles and practices - but testability wins - This makes things testable with minimal smell in the rest of the code.
Just to be clear - this is the edge of our system now, it gets slightly different rules to ensure the rest of our system is great code.

I modify the BookEnd to include a shim of a _testClient.

internal class HttpClientBookEnd : IHttpClientBookEnd{
    private static HttpClient _testClient;
#if DEBUG
    public static void SetTestClient(HttpClient testClient) => _testClient = testClient;
#endif
    public readonly HttpClient _httpClient;
    public HttpClientBookEnd() : this(new HttpClient()){}
    private HttpClientBookEnd(HttpClient httpClient) => _httpClient = httpClient;
    public void SendRequestAsync(HttpRequestMessage msg) => Client().SendRequestAsync(msg);
    private HttpClient() => _testClient ?? _httpClient;
}

The shim allows a unit test to look like

[TestMethod, TestCategory("unit")]
public void ExampleShim(){
    HttpMessageHandler fakeHttpMessageHandler = new FakeHttpMessageHandler();
    HttpClient testClient = new HttpClient(fakeHttpMessageHandler);
    HttpClientBookEnd.SetTestClient(testClient);
    HttpClientBookEnd client = new HttpClientBookEnd();
    client.SendRequestAsync(null);
}

A correctly configured HttpMessageHandler (an OLD example here) will allow customization of the responses to exactly what's being expected.

In .netCore, use IHttpFilter instead of HttpMessageHandler. There's a few other differences as well, but that's a post for another day.

Book End

Using this BookEnd method has simplified my integration tests and increased my ability to do code-integration tests.

The huge increase of testability allows me to get to 100% test coverage of everything that isn't the literal edge of the system.

Show Comments