DEV Community

Stuart Lang
Stuart Lang

Posted on • Originally published at stu.dev on

Testing with HttpClient Interception

In my last post I showed how to automatically generate a typed client for use with HttpClientFactory from a swagger file. Now I want to make changes to the client's behaviour and need unit tests, in this post I will look at the HttpClient Interception library and how we can effectively use it in our tests.

Take a look at the following code that makes use of our previously generated client.

var pet = await petStoreClient.GetPetByIdAsync(1, ct);
var firstTag = pet.Tags.FirstOrDefault();

This looks fine, right? But what happens if Tags is null? Can that event happen?

The quick fix would be to use ?. to propagate null safety, but maybe we want our deserialization to never allow collections to be null, but to instead default it to an empty collection.

To figure out the current behaviour and to document how we'd like it to work, we should write tests.

I will be writing these tests in F#, 'cos it rocks! Not only is it a really nice language, I want to use string literals containing JSON in my tests, and this is painful in C#.

HttpClient Interception

This library is really slick, it has a fluent API that can produce an HttpClient loaded with an HttpClientHandler that will intercept requests and return configured responses, so it's great for unit testing.

The fluent API is really intuitive, you can see some great examples on the README.

module NSwag.PetStore.Client.Tests.Tests

open System
open Xunit
open NSwag.PetStore.Client
open JustEat.HttpClientInterception

[<Fact>]
let ``Given missing collection property, we deserialize to an empty collection. πŸš€`` () =

    let builder =
        HttpRequestInterceptionBuilder()
            .Requests().ForAnyHost().ForPath("/pet/1234")
            .Responds().WithContent """{
  "id": 0,
  "name": "doggie",
  "photoUrls": [
    "string"
  ],
  "status": "available"
}"""

    let options =  HttpClientInterceptorOptions().ThrowsOnMissingRegistration()
    builder.RegisterWith options |> ignore
    use innerClient = options.CreateHttpClient()

    innerClient.BaseAddress <- "http://test-host" |> Uri

    let client = PetStoreClient(innerClient)
    let pet = client.GetPetByIdAsync(1234L) |> Async.AwaitTask |> Async.RunSynchronously

    Assert.NotNull pet.Tags
    Assert.Empty pet.Tags

Some things to call out:

  • We're using xUnit, another great choice would be Expecto, however there's no test runner yet for Rider, and xUnit will work just fine here. xUnit works pretty well with F#, we don't have to have class definitions, notice how we have a module here, but as soon as we want to use ITestOutputHelper we have to switch to using classes.
  • Function names in F# can contain pretty much anything by escaping with double-ticks: spaces, punctuation, even emojis (which means you have to use them, surely!).
  • String literals can contain " when we use triple-quoted strings - this is great for JSON and XML snippets.

In terms of what we are doing with HttpClient Interceptor, it almost needs no explanation as it's completely declarative, the only comment I'd add is that you'll probably want the ThrowsOnMissingRegistration during tests, as this ensures everything is completely in-memory, and nothing passes through and materialises to an actual HTTP request .

As we suspected!

Right, so if the Tag part of the JSON is omitted then we end up with a null collection, which is not what we want, so let's change it.

NSwag lets us control the serialization settings in a few ways, one way is via the jsonSerializerSettingsTransformationMethod setting, it lets us point to a static method to configure the JsonSerializerSettings.

After lots of investigation, I found that this was the simplest way to achieve the deserialization that I wanted:

using System;
using System.Collections.Generic;
using System.Reflection;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;

namespace NSwag.PetStore.Client
{
    internal static class PetStoreSerializerSettings
    {
        public static JsonSerializerSettings TransformSettings(JsonSerializerSettings settings)
        {
            settings.DefaultValueHandling = DefaultValueHandling.Populate;
            settings.ContractResolver = new NullToEmptyListResolver();
            return settings;
        }
    }

    internal sealed class NullToEmptyListResolver : DefaultContractResolver
    {
        protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization)
        {
            var property = base.CreateProperty(member, memberSerialization);

            var propType = property.PropertyType;
            if (propType.IsGenericType && 
                propType.GetGenericTypeDefinition() == typeof(ICollection<>))
                property.NullValueHandling = NullValueHandling.Include;
            return property;
        }

        protected override IValueProvider CreateMemberValueProvider(MemberInfo member)
        {
            var provider = base.CreateMemberValueProvider(member);

            if (member.MemberType == MemberTypes.Property)
            {
                var propType = ((PropertyInfo)member).PropertyType;
                if (propType.IsGenericType && 
                    propType.GetGenericTypeDefinition() == typeof(ICollection<>))
                {
                    return new EmptyListValueProvider(provider, propType);
                }
            }

            return provider;
        }

        class EmptyListValueProvider : IValueProvider
        {
            readonly IValueProvider innerProvider;
            readonly object defaultValue;

            public EmptyListValueProvider(IValueProvider innerProvider, Type listType)
            {
                this.innerProvider = innerProvider;
                defaultValue = Array.CreateInstance(listType.GetGenericArguments()[0], 0);
            }

            public void SetValue(object target, object value) => innerProvider.SetValue(target, value ?? defaultValue);
            public object GetValue(object target) => innerProvider.GetValue(target) ?? defaultValue;
        }
    }
}

If there is a simpler way (and I suspect there must be!) do let me know in the comments.

Now when we run the tests again...

That's better πŸ™‚

Other Options with HttpClient Interception

In this case I wanted my test to contain a JSON string literal, but in most cases we could instead use the WithJsonContent method, which takes an object and serializes it for us (optionally with serialization settings). Ironically it would have been easier in C# if I wanted to do this, as anonymous objects hasn't yet landed in F# (but it's sooo close!).

A compelling alternative would have been to use an HTTP bundle file, which to quote the docs, β€œcan be used to store the HTTP requests to intercept and [store] their corresponding responses as JSON”.

Closing

HttpClient Interception is the brainchild of Martin Costello, who's always doing amazing things in the .NET Core community.

You can find all of the code in this post here.

Top comments (0)