.netintegration-testingjsonconvert

JsonConverter not working in integration tests


I've enabled my API to serialize/deserialize enum using string values. To do that I've added JsonStringEnumConverter to the list of supported JsonConverters in my API's Startup class:

.AddJsonOptions(opts =>
{
    var enumConverter = new JsonStringEnumConverter();
    opts.JsonSerializerOptions.Converters.Add(enumConverter);
});

It works fine- my API succeessfully serialize and deserialize enums as a string.

Now- I'm trying to build integration test for my API and having some issue. I'm using HttpContentJsonExtensions.ReadFromJsonAsync to deserialize the API response but an exception is thrown over an enum property.

The problem is obvious- HttpContentJsonExtensions.ReadFromJsonAsync is not aware to the list of converters used by the API (since, as I mentioned earlier, I've added the JsonStringEnumConverter to the list of supported converters and it works fine).

If I do this in my test function:

var options = new System.Text.Json.JsonSerializerOptions();
options.Converters.Add(new JsonStringEnumConverter());
SomeClass result= await response.Content.ReadFromJsonAsync<SomeClass>(options);

Then the enum property is deserialized and no exception is thrown. However now, ReadFromJsonAsync is only aware of JsonStringEnumConverter and not to other JSON converters that are used by the API (like Guid converter)

How can I make sure that HttpContentJsonExtensions.ReadFromJsonAsync will be able to use all JSON converters that are used by the API?

Thanks!


Solution

  • There is, unfortunately, no way to achieve this out of the box. The reason is that the "designers" of the System.Text.Json APIs decided, in an unbelievably baffling move, to make said APIs static - probably to mimic the well-know Newtonsoft.Json - but static APIs, of course, cannot carry state along with them. For more context I refer you to the feature request to fix this poor design.

    I actually came up with a solution in that FR, which I've tweaked a little since I concocted it:

    public interface IJsonSerializer
    {
        JsonSerializerOptions Options { get; }
    
        Task<T> DeserializeAsync<T>(Stream utf8Json, CancellationToken cancellationToken);
    
        // other methods elided for brevity
    }
    
    public class DefaultJsonSerializer : IJsonSerializer
    {
        private JsonSerializerOptions _options;
        public JsonSerializerOptions Options
            => new JsonSerializerOptions(_options); // copy constructor so that callers cannot mutate the options
    
        public DefaultJsonSerializer(IOptions<JsonSerializerOptions> options)
            => _options = options.Value;
    
        public async Task<T> DeserializeAsync<T>(Stream utf8Json, CancellationToken cancellationToken = default)
            => await JsonSerializer.DeserializeAsync<T>(utf8Json, Options, cancellationToken);
    
        // other methods elided for brevity
    }
    

    Essentially you define a wrapper interface with method signatures identical to the static ones of JsonSerializer (minus the JsonSerializerOptions parameter as you want to use the ones defined on the class), then a default implementation of said interface that delegates to the static JsonSerializer methods. Map the interface to the implementation in Startup.ConfigureServices and instead of calling the static JsonSerializer methods in the classes that need to handle JSON, you inject your JSON interface into those classes and use it.

    Given that you're using HttpContentJsonExtensions, you will additionally need to define your own wrapper version of that extension class that duplicates its method signatures but replaces their JsonSerializerOptions parameters with instances of your JSON serialization interface, then passes said interface's options through to the underlying HttpContentJsonExtensions implementation:

    public static class IJsonSerializerHttpContentJsonExtensions
    {
        public static Task<object?> ReadFromJsonAsync(this HttpContent content, Type type, IJsonSerializer serializer, CancellationToken cancellationToken = default)
            => HttpContentJsonExtensions.ReadFromJsonAsync(content, type, serializer.Options, cancellationToken);
    
        // other methods elided for brevity
    }
    

    Is it painful? Yes. Is it unnecessary? Also yes. Is it stupid? A third time, yes. But such is Microsoft.