Mocking HttpClient requests for C# unit tests

Integrating with an external API is something that I do over and over again. While a lot of APIs have clients built specifically for .NET, spinning up a new HttpClient is at least a monthly pleasure. Calling an API using HttpClient is easy, but unit testing code using a HttpClient is not always a walk in the park. In this post, I'll introduce you to a way of mocking requests when using a HttpClient.

Mocking HttpClient requests for C# unit tests

For the rest of this post, I'll use a simple example. I have implemented a quick CLI that can output jokes using the free JokeAPI. Who doesn't want a CLI dedicated to generating programming jokes? The joke fetching is implemented in a Jokes class:

public class Jokes(HttpClient httpClient)
{
    private readonly HttpClient httpClient = httpClient;

    public async Task GenerateJoke()
    {
        var result =
            await httpClient.GetAsync("https://v2.jokeapi.dev/joke/Programming?type=twopart");
        result.EnsureSuccessStatusCode();

        var body = await result.Content.ReadAsStringAsync();
        if (string.IsNullOrWhiteSpace(body)) return;

        var joke = JsonSerializer.Deserialize<Joke>(body);
        if (joke == null || joke.Error.HasValue && joke.Error.Value) return;

        Console.WriteLine(joke.Setup);

        Thread.Sleep(TimeSpan.FromSeconds(5));

        Console.WriteLine(joke.Delivery);
    }

    public class Joke
    {
        [JsonPropertyName("error")]
        public bool? Error { get; set; }

        [JsonPropertyName("setup")]
        public string? Setup { get; set; }

        [JsonPropertyName("delivery")]
        public string? Delivery { get; set; }
    }
}

The class accepts a HttpClient to call the jokes API. The GenerateJoke method creates an async request, asking for a two-parted joke. After a bit of error handling, the joke is outputted to the console with a sleep in-between for the user to think about potential punchlines.

Calling the code from the Program class is straightforward:

public static class Program
{
    public static async Task Main()
    {
        var httpClient = new HttpClient();
        var jokes = new Jokes(httpClient);
        await jokes.GenerateJoke();
    }
}

Here's the tool in action:

What do you get if you lock a monkey in a room with a typewriter for 8 hours?
A regular expression.

We can write a test for the Jokes class like this:

public class JokesTest
{
    [Test]
    public async Task CanGenerateJoke()
    {
        // Arrange
        var stringWriter = new StringWriter();
        Console.SetOut(stringWriter);
        var httpClient = new HttpClient();
        var jokes = new Jokes(httpClient);

        // Act
        await jokes.GenerateJoke();

        // Assert
        var output = stringWriter.ToString().Trim();
        Assert.That(output, Is.Not.Null);
        var lines = output.Split(Environment.NewLine).ToList();
        Assert.That(lines.Count, Is.EqualTo(2));
        Assert.That(lines.Exists(line => string.IsNullOrWhiteSpace(line)), Is.False);
    }
}

The test method is implemented using NUnit but it could be any test framework of your choice. The test uses a StringWriter and the Console.SetOut method to intercept text written to the console. The three asserts will verify that two lines of text has been outputted to the console.

The downside of the test is that it is an integration test, dependent on the JokeAPI being available. This means that if the API is down, our test will fail. We've finally reached the topic of discussion for this post. How do we mock the HttpClient to not call the real API?

Would your users appreciate fewer errors?

➡️ Reduce errors by 90% with elmah.io error logging and uptime monitoring ⬅️

To intercept an HTTP request made on the HttpClient we can implement what's called an HTTP message handler. In C#, a message handler is implemented by extending the abstract HttpMessageHandler class. An HttpMessageHandler can be used to customize the behavior of HTTP requests and responses. HttpClient uses HttpMessageHandler internally to send HTTP requests and receive responses, but you can also provide your own custom HttpMessageHandler to modify or extend its behavior. Besides the testing scenario presented in this post, a custom HttpMessageHandler can also be used to:

  • Adding custom headers and/or authentication.
  • Logging or tracing (like logging errors to elmah.io 😉).
  • Handling HTTP errors and/or retries.

Let's create an implementation of a HttpMessageHandler that returns static JSON:

public class StaticJsonHandler(string json) : HttpMessageHandler
{
    private readonly string json = json;

    sealed protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var response = new HttpResponseMessage()
        {
            StatusCode = HttpStatusCode.OK,
            Content = new StringContent(json, Encoding.UTF8, "application/json"),
        };

        return Task.FromResult(response);
    }
}

There's only a single method to override here, the SendAsync method. The implementation will short-circuit all real HTTP communications and just set the JSON response provided through the constructor.

Changing the test to a unit test now only requires an instance of the new message handler alongside some static JSON:

var joke = new Jokes.Joke
{
    Error = false,
    Setup = ".NET developers are picky when it comes to food.",
    Delivery = "They only like chicken NuGet.",
};

var jokeJson = JsonSerializer.Serialize(joke);

var jsonMessageHandler = new StaticJsonHandler(jokeJson);

var httpClient = new HttpClient(jsonMessageHandler);

The code generates some valid JSON by re-using the Joke model with some random data. Notice how the StaticJsonHandler object is provided in the constructor for the HttpClient.

The assert part can now be turned into more specific checks since we always know the joke outputted to the console:

public class JokesTest
{
    [Test]
    public async Task CanGenerateJoke()
    {
        // Arrange
        var stringWriter = new StringWriter();
        Console.SetOut(stringWriter);

        var joke = new Jokes.Joke
        {
            Error = false,
            Setup = ".NET developers are picky when it comes to food.",
            Delivery = "They only like chicken NuGet.",
        };

        var jokeJson = JsonSerializer.Serialize(joke);

        var jsonMessageHandler = new StaticJsonHandler(jokeJson);

        var httpClient = new HttpClient(jsonMessageHandler);
        var jokes = new Jokes(httpClient);

        // Act
        await jokes.GenerateJoke();

        // Assert
        var output = stringWriter.ToString().Trim();
        Assert.That(output, Is.Not.Null);
        var lines = output.Split(Environment.NewLine).ToList();
        Assert.That(lines.Count, Is.EqualTo(2));
        Assert.That(lines.First(), Is.EqualTo(joke.Setup));
        Assert.That(lines.Last(), Is.EqualTo(joke.Delivery));
    }
}

Re-using the StaticJsonHandler we could extend the tests with more tests including strange characters in the response, cancelation of the request, and even simulate that the HTTP request fails to make sure proper error handling is implemented.

The purpose of this post has been to show how to mock a HttpClient without any dependencies. Many mocking frameworks out there offer various levels of support for implementing code like this (maybe check out this post for a comparison of mocking frameworks). There is also mockhttp that implements a HttpMessageHandler to help writing unit tests similar to the approach taken in this post.

elmah.io: Error logging and Uptime Monitoring for your web apps

This blog post is brought to you by elmah.io. elmah.io is error logging, uptime monitoring, deployment tracking, and service heartbeats for your .NET and JavaScript applications. Stop relying on your users to notify you when something is wrong or dig through hundreds of megabytes of log files spread across servers. With elmah.io, we store all of your log messages, notify you through popular channels like email, Slack, and Microsoft Teams, and help you fix errors fast.

See how we can help you monitor your website for crashes Monitor your website