Writing Consumer Tests for messages containing abstract types

152 views Asked by At

I am currently trying to introduce consumer driven contract tests to an existing C# codebase. Currently I am only looking into testing messages!

While trying to create the consumer tests for some messages, I have run into a problem with abstract types.

Let me explain with an example.

Assume we have the following message:

public abstract record KeyValuePair(string Key);
public record IntegerKeyValuePair(string Key, int IntegerValue) : KeyValuePair(Key);
public record DoubleKeyValuePair(string Key, double DoubleValue) : KeyValuePair(Key);

public record Message(KeyValuePair[] KeyValuePairs);

Now we write the following consumer test:

public class StackOverflowConsumerTest
{
    private readonly IMessagePactBuilderV3 _pact;

    public StackOverflowConsumerTest()
    {
        this._pact = Pact.V3(
                "StackOverflowConsumer",
                "StackOverflowProvider",
                new PactConfig { PactDir = "./pacts/" })
            .WithMessageInteractions();
    }

    [Fact]
    public void ShouldReceiveExpectedMessage()
    {
        var message = new
        {
            KeyValuePairs = Match.MinType(
                new
                {
                    Key = Match.Type("SomeValue"),
                    DoubleValue = Match.Decimal(12.3)
                }, 1)
        };

        _pact
            .ExpectsToReceive(nameof(Message))
            .WithJsonContent(message)
            .Verify<Message>(msg =>
            {
                Assert.Equal(new DoubleKeyValuePair("SomeValue", 12.3), msg.KeyValuePairs.Single());
            });
    }
}

Running this sonsumer test will then fail, because I can't deserialize the message from the JSON representation that Pact.Net + Newtonsoft generate:

Newtonsoft.Json.JsonSerializationException: Could not create an instance of type PactNet.Learning.KeyValuePair. Type is an interface or abstract class and cannot be instantiated.

I would usually handle situations like this by changing the serializer settings to include the actual type that gets serialized. However, here the actual objects are never really passed to the serializer. Instead it receives some dynamic type structure containing the matchers that I defined with the expected values. Is there a way to handle abstract types with Pact.Net's matchers that I overlooked or is this simply not possible?

One solution to my problems might be to "just refactor the messages", but that is not what I can do right now.

2

There are 2 answers

0
cxanter On BEST ANSWER

While it is not the perfect solution, I have an answer to my own question. It does have some shortcomings, but it will address the basic problem.

The solution starts by creating my own type that extends a Dictionary<string, object>, similar to ExpandoObject, but also allows you to specifiy the contained type (first shortcoming: the class could check if its properties actually match the expected type). I have also added and interface for easier handling.

public interface IMatcherWrapper : IDictionary<string, object>
{
    Type ContentType { get; }
}

public sealed class MatcherWrapper<TContent> : Dictionary<string, object>, IMatcherWrapper
{
    public Type ContentType => typeof(TContent);
}

As a next step we will need a custom JsonConverter, that takes the value of the ContentType property and encodes it into the $type JSON property (if type name handling is activated)

public class MyExpandoObjectConverter : JsonConverter
{
    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
    {
        var json = new JObject();

        var matcherWrapper = (IMatcherWrapper)value;

        if (serializer.TypeNameHandling != TypeNameHandling.None)
        {
            var type = matcherWrapper.ContentType;
            // $type needs to be first key!
            json["$type"] = $"{type}, {type.Assembly.GetName().Name}";
        }

        foreach ((string key, object obj) in matcherWrapper)
        {
            // property names need to match the used CamelCasePropertyNamesContractResolver 
            var k = System.Text.Json.JsonNamingPolicy.CamelCase.ConvertName(key);
            json[k] = JToken.FromObject(obj, serializer);
        }

        json.WriteTo(writer);
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) =>
        throw new NotImplementedException();

    public override bool CanConvert(Type objectType) =>
        objectType
            .GetInterfaces()
            .Contains(typeof(IMatcherWrapper));
}

In the test setup we need to make sure that we use the correct serializer settings. (Next shortcoming: this example is very reliant on using exactly these settings. However, in the provider tests you can skip the converter.)

public StackOverflowConsumerTest(ITestOutputHelper output)
{
    var pact = Pact.V3(
        "StackOverflowConsumer",
        "StackOverflowProvider",
        new PactConfig
        {
            PactDir = "./pacts/",
            DefaultJsonSettings = new JsonSerializerSettings
            {
                TypeNameHandling = TypeNameHandling.Objects,
                ContractResolver = new CamelCasePropertyNamesContractResolver(),
                Converters = new List<JsonConverter> { new MyExpandoObjectConverter() }
            }
        });

    _pact = pact.WithMessageInteractions();
}

And now we can finally create a test based on our new wrapper class:

[Fact]
public void ShouldReceiveExpectedMessageUsingCustomConverter()
{
    var message = new MatcherWrapper<Message>
    {
        ["KeyValuePairs"] = Match.MinType(
            new MatcherWrapper<DoubleKeyValuePair>
            {
                ["Key"] = Match.Type("SomeValue"),
                // shortcoming with naive implementation:
                // provider will need to put DoubleKeyValuePair into the array!
                ["DoubleValue"] = Match.Decimal(12.3)
            }, 1)
    };

    _pact
        .ExpectsToReceive(nameof(Message))
        .WithJsonContent(message)
        .Verify<Message>(msg =>
        {
            Assert.Equal(new DoubleKeyValuePair("SomeValue", 12.3), msg.KeyValuePairs.Single());
        });
}

Here we see the next shortcoming, the test specifies exactly which sub-type of my abstract type to use and the provider will need to match it. You can probably work around this, but it is outside the scope of this answer.

Hope it helps.

Edit: You can also have a look at my github repo for a working example: Link

3
Georg Schwarz On

If I understand the Pact.NET library correctly (and I'm not entirely sure about that), then the problematic deserialization should happen in ConfiguredMessageVerifier.MessageReified

If this is the case you can specify the PactConfig.DefaultJsonSettings with a custom JsonSerializerSettings in the Pact.V3 initialization (your 3rd parameter).

The withJsonContent method should be overloaded to allow passing a custom JsonSerializerSettings as well.

I'm not too familiar with .NET, so I'm unsure if configuring a custom JsonSerializerSettings will be enough to serialize and deserialize your abstract types correctly. Injecting a whole serialization / deserializing method instead of relying on Newtonsoft.Json is not possible as far as I can see.


Edit: until here, it does not solve the problem since dynamic objects are used (see answer below). Here is a theoretical attempt to how it might be solvable (I haven't tried it yet). Inspiration is this post: https://stackoverflow.com/a/65261072/12256497

Would it be possible to introduce records specifically used for pact and use them instead of dynamic objects? Schematically (I don't know if the Matcher types are correct):

public abstract record PactKeyValuePair(StringMatcher Key);
public record IntegerPactKeyValuePair(StringMatcher Key, IntMatcher IntegerValue) : PactKeyValuePair(Key);
public record DoublePactKeyValuePair(StringMatcher Key, DecimalMatcher DoubleValue) : PactKeyValuePair(Key);

public record PactMessage(PactKeyValuePair[] KeyValuePairs);

Then you could register a custom ISerializationBinder with the JsonSerializerSettings that does the following:

  • serializes the PactKeyValuePair to an artificial $type (e.g., "WorkaroundKeyValuePair") in the BindToName method
  • deserializes from the artificial $type to KeyValuePair (so the original without matchers) in the BindToType method.