Skip to content

Instantly share code, notes, and snippets.

@farhad-taran
Last active August 24, 2021 19:23
Show Gist options
  • Save farhad-taran/93232300cd172c1db16c8c35960ed7ff to your computer and use it in GitHub Desktop.
Save farhad-taran/93232300cd172c1db16c8c35960ed7ff to your computer and use it in GitHub Desktop.
Comparing two json objects in automated tests

In some scenarios it is necessary to compare the structure of json documents in your automated tests. for example you might want to make sure that the json representation of what is in your storage, mirrors whats returned by to what is returned from your apis.

its also possible that these two different sources have some fields missing or that they serialize the data in different ways, for example a json string might represent numbers as single digit unless explicitly made to use decimals.

the following c# object allows for comparing two expando objects, the idea is to load a json string representation as an expando object using any json parser that you like, and be able to compare it to another. using expando objects makes this implementation agnostic to different json serializers.

using System;
using System.Collections;
using System.Collections.Generic;
using System.Dynamic;
using System.Linq;
using System.Text.RegularExpressions;

namespace Tests
{
    public class ExpandoCompare
    {
        private readonly ExpandoObject _expected;
        private readonly ExpandoObject _actual;
        private readonly IList<Regex> _ignoredPaths;
        private readonly IList<(Func<Type> typeMatch, Func<(object expected, object actual),bool> assert)> _typeMatcher;

        private ExpandoCompare(ExpandoObject expected, ExpandoObject actual)
        {
            _expected = expected;
            _actual = actual;
            _ignoredPaths = new List<Regex>();
            _typeMatcher = new List<(Func<Type> typeMatch, Func<(object expected, object actual), bool> assert)>();
        }

        public static ExpandoCompare Compare(ExpandoObject expected, ExpandoObject actual) =>
            new ExpandoCompare(expected, actual);

        private static string AppendPath(string currentPath, string nodeName) => $"{currentPath}.{nodeName}";

        private void CheckValue(object expected, object actual, string path, IList<(string path, string message)> messages)
        {
            (Func<Type> typeMatch, Func<(object expected, object actual), bool> assertEqual) typeOverride = _typeMatcher.FirstOrDefault(t => t.typeMatch() == expected.GetType());
            if (typeOverride.assertEqual != null)
            {
                bool equal = typeOverride.assertEqual((expected, actual));
                if (!equal)
                {
                    messages.Add((path, $"type override for {expected.GetType()} failed equality, expected value is {expected} but actual value is {actual}"));
                }
                return;
            }

            var actualValue = actual ?? "null";
            var actualType = actual?.GetType().ToString() ?? "null";
            if (!expected.Equals(actual))
            {
                messages.Add((path, $"expected value is {expected}:{expected.GetType()} but actual value is {actualValue}:{actualType}"));
            }
        }

        private bool IsPathIgnored(string path)
        {
            return _ignoredPaths.Any(regex =>
            {
                var isMatch = regex.IsMatch(path);
                return isMatch;
            });
        }

        private void Collect(dynamic expected, dynamic actual, string path, IList<(string path, string message)> messages)
        {
            if (expected is ExpandoObject == false)
            {
                CheckValue(expected, actual, path, messages);
                return;
            }

            var actualProps = (IDictionary<string, object>)actual;

            foreach (var kvp in expected)
            {
                var currentPath = AppendPath(path, kvp.Key);

                if (IsPathIgnored(currentPath))
                {
                    continue;
                }
                
                if (actualProps.TryGetValue(kvp.Key, out object actualValue) == false)
                {
                    messages.Add((currentPath, $"expected type has {kvp.Key}, but its missing in actual"));
                    continue;
                }
                
                var expectedValue = kvp.Value;
                var expectedType = kvp.Value.GetType();
                if (expectedType == typeof(ExpandoObject))
                {
                    Collect(expectedValue, actualValue, currentPath, messages);
                    continue;
                }
                if (expectedType != typeof(string) && expectedType.GetInterface(nameof(IEnumerable)) != null)
                {
                    var actualCollection = (IList)actualValue;
                    if (actualCollection == null)
                    {
                        messages.Add((currentPath, "expected has a collection but actual is null"));
                        continue;
                    }
                    var expectedCollection = (IList)expectedValue;
                    for (var i = 0; i < expectedCollection.Count; i++)
                    {
                        var childPath = $"{currentPath}.[{i}]";
                        Collect(expectedCollection[i], actualCollection[i], childPath, messages);
                    }
                    continue;
                }
                
                Collect(expectedValue,actualValue,currentPath,messages);
            }
        }

        public ExpandoCompare Ignore(string regex)
        {
            _ignoredPaths.Add(new Regex(regex));
            return this;
        }

        public ExpandoCompare Customize(Func<Type> typeMatch, Func<(object expected, object actual), bool> isMatch)
        {
            _typeMatcher.Add((typeMatch,isMatch));
            return this;
        }

        public void Assert()
        {
            var messages = new List<(string path, string message)>();
            Collect(_expected, _actual, string.Empty, messages);
            if (!messages.Any()) return;
            var message = string.Join("\n", messages.Select(kvp => $"{kvp.path}\t{kvp.message}"));
            throw new Exception(message);
        }
    }
}

you can then use the above class like so:

private void AssertCanSerializeAndDeserializeToSameThing(string originalJson)
{
    ExpandoObject originalJsonExpando = JsonConvert.DeserializeObject<ExpandoObject>(originalJson);
    var order = JsonConvert.DeserializeObject<Order>(originalJson);

    //both lambda base and api use newtonsoft using the following settings
    var orderJson = JsonConvert.SerializeObject(order, new JsonSerializerSettings()
    {
        NullValueHandling = NullValueHandling.Ignore,
        Converters = new List<JsonConverter>()
        {
            new EnumStringConverter()
        }
    });

    var orderExpando = JsonConvert.DeserializeObject<ExpandoObject>(orderJson, new ExpandoObjectConverter());

    ExpandoCompare.Compare(originalJsonExpando, orderExpando)
        // it seems that we are not storing specific properties and therefore they are ignored in tests
        // if we decide to return them from this service then we should modify these ignore lists and update the tests
        .Ignore(".deals.\\[\\d].pizzas.\\[\\d].defaults")
        .Ignore(".deals.\\[\\d\\].desserts.\\[\\d\\].taxCode")
        .Ignore(".deals.\\[\\d\\].pizzas.\\[\\d\\].taxCode")
        .Ignore(".deals.\\[\\d\\].sides.\\[\\d\\].taxCode")
        .Ignore(".deals.\\[\\d\\].sides.\\[\\d\\].modifiers")
        .Ignore(".deals.\\[\\d\\].pizzas.\\[\\d\\].sauce.servingCalories")
        // the json files represent single digit numbers as ints but the api returns them as decimal so we are comparing them for decimal points
        .Customize(() => typeof(Int64), values => Convert.ToDecimal(values.expected) == Convert.ToDecimal(values.actual))
        .Assert();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment