Mock HttpClient and IHttpClientFactory with Moq.Contrib.HttpClient (Part 2)

Last week, in my previous post, I wrote about the Moq.Contrib.HttpClient library that makes unit-testing C# code that deals with HttpClient and IHttpClientFactory much easier to work with. A couple of days later, this happened.

maxkagamine commented: Hi Tom! I was surprised to see an article about my library, thanks so much!

I noticed you found it rather verbose having to write a helper class. You don't actually need to do that! The whole point of Moq.Contrib.HttpClient is to get rid of Protected() and other boilerplate, so you can just do setups like you normally would for a method call. There are also response helpers for sending strings and JSON and such, so in most cases you don't need to create HttpResponseMessages and StringContents etc yourself, either.

I've taken the liberty of updating your sample code to show what it would look like using those helpers. Let me know if you have any questions!

So, first of all, a big thank you to Max Kagamine, the creator of the Moq.Contrib.HttpClient library for reviewing my blog post and for the pull-request that was made to teach me a few additional things about the library. While what I showed in the previous post works, Max pointed out a few ways to simplify the testing process.

In my approach, I had created a helper class called HttpTestingUtility to tuck away some of the “verbosity” of the setup part of the test. This helper class did primarily three things:

  • You can pass it a name so that it can create a “named client” with that name.
  • You can pass in a mocked response in the form of HttpResponseMessage and your fake HttpClient will then return that mocked response when it is invoked. As part of this mocked response, you can also specify the HTTP Status it should produce – a 200 OK response, or a 400 Bad Request, or whatever.
  • It exposed the mocked HttpMessageHandler and the instance of the IHttpClientFactory so that you can make assertions with them.

Well, I learned that you can do a lot of that inline within your test itself and still keep the number of lines to a minimum. For example, to test that your “real code” is making an external HTTP call that is a GET method, to a particular endpoint and it returns a JSON array of some numbers, you can do that like this, without the need of a separate helper class/method:

[Fact]
public async Task Handle_CallsRandomNumberExternalApi()
{
    // Arrange
    var handler = new Mock<HttpMessageHandler>(MockBehavior.Strict);
    var factory = handler.CreateClientFactory();

    var numbers = new int[] { 100, 99, 98, 97, 96, 95, 94, 93, 92, 91 };

    // See "MatchesQueryParameters" here for tips on matching dynamic query strings:
    // https://github.com/maxkagamine/Moq.Contrib.HttpClient/blob/master/Moq.Contrib.HttpClient.Test/RequestExtensionsTests.cs

    handler.SetupRequest(HttpMethod.Get, "http://www.randomnumberapi.com/api/v1.0/random?min=26&max=38&count=10")
        .ReturnsJsonResponse(numbers);

    var sut = new GetWeatherForecastHandler(factory);

    // Act
    await sut.Handle(new WeatherForecastRequest(), new CancellationToken());

    // Assert
    handler.VerifyAll();
}

Above, I’m using the handler’s SetupRequest method, passing in the HttpMethod (GET) and the API URL. Then we’re chaining on the ReturnsJsonReponse with the payload that it should return. All that can be done in a couple of lines without the need for an external helper method. The VerifyAll() will confirm that your production code is making the same HTTP call that you’ve setup in your test, utilizing the same HTTP method, returning the same output that you’ve mocked and the call is invoked exactly the same number of times you’ve specified in the test.

Is it really working?

If you’re like me, you want to go in and make sure that these tests are doing what you think they’re doing. Try changing the URL that either your test setup code is using or try changing the same in your production code. In either case, your test will fail (as it should), confirming to you that these mock helpers are in fact doing their jobs correctly. Try mismatching the HTTP method or a query string param or throw in an extra HTTP call somewhere. The tests will turn red, as they should.

Here are the couple of other tests that I had written that Max had graciously modified to fit this new approach:

[Fact]
public async Task Handle_Returns5DayForecast()
{
    // Arrange
    var handler = new Mock<HttpMessageHandler>(MockBehavior.Strict);
    var factory = handler.CreateClientFactory();

    var numbers = new int[] { 100, 99, 98, 97, 96, 95, 94, 93, 92, 91 };

    handler.SetupRequest(HttpMethod.Get, "http://www.randomnumberapi.com/api/v1.0/random?min=26&max=38&count=10")
        .ReturnsJsonResponse(numbers);

    var sut = new GetWeatherForecastHandler(factory);

    // Act
    var results = await sut.Handle(new WeatherForecastRequest(), new CancellationToken());

    // Assert
    Assert.Equal(5, results.WeatherForecasts.Count());
    Assert.Equal(numbers.Skip(1).Take(5), results.WeatherForecasts.Select(x => x.TemperatureC));
}

[Fact]
public async Task Handle_ThrowsErrorWhenRandomNumberApiCallFails()
{
    // Arrange
    var handler = new Mock<HttpMessageHandler>(MockBehavior.Strict);
    var factory = handler.CreateClientFactory();

    handler.SetupAnyRequest()
        .ReturnsResponse(HttpStatusCode.ServiceUnavailable);

    var sut = new GetWeatherForecastHandler(factory);

    // Act & Assert
    await Assert.ThrowsAsync<HttpRequestException>(async () =>
    {
        await sut.Handle(new WeatherForecastRequest(), new CancellationToken());
    });
}

Closing Remarks

Please go check out Max’s library at maxkagamine/Moq.Contrib.HttpClient: A set of extension methods for mocking HttpClient and IHttpClientFactory with Moq. (github.com). Give it a ⭐️. Also, you can go checkout my update repository on my GitHub account, here:

tvaidyan/httpclient-testing-demo: Companion repo to my “Mock HttpClient and IHttpClientFactory with Moq.Contrib.HttpClient” on tvaidyan.com (github.com)

Leave a Comment

Your email address will not be published. Required fields are marked *