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.
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