Skip to content

Instantly share code, notes, and snippets.

@JulianMay
Last active November 25, 2016 07:37
Show Gist options
  • Save JulianMay/eeb533d075d5d2c7f3e4cc7746a9a5da to your computer and use it in GitHub Desktop.
Save JulianMay/eeb533d075d5d2c7f3e4cc7746a9a5da to your computer and use it in GitHub Desktop.
"By example" assertions of json-documents
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using System.Text;
namespace ProjectName.Tests.System.json
{
public class JsonComparer
{
private const string SuperSecretWildcard = "@£$DONTeverUSEthisINtests_WILDCARD$£@";
private const string SuperSecretAnyString = "@£$DONTeverUSEthisINtests_STRING$£@";
private const string SuperSecretAnyNumber = "@£$DONTeverUSEthisINtests_NUMBER$£@";
private const string WildcardType = "#Wildcard#";
private const string StringType = "#String#";
private const string NumberType = "#Number#";
public class Result
{
public Difference Report;
public string ExpectedPretty;
public string ActualPretty;
}
public class Difference
{
public string ObjectName;
public string DescriptionOfDifference;
public List<Difference> UnderLyingDifferences = new List<Difference>();
public bool HasUnexpectedDifferences
{ get
{
return !string.IsNullOrEmpty(DescriptionOfDifference) || UnderLyingDifferences.Any(x=>x.HasUnexpectedDifferences);
}
}
public virtual string Report(IEnumerable<string> path)
{
if (!HasUnexpectedDifferences)
return string.Empty;
var b = new StringBuilder();
if (!string.IsNullOrEmpty(DescriptionOfDifference))
b.AppendLine(string.Format("at '{0}', Property '{1}': {2}",
string.Join("->", path), ObjectName, DescriptionOfDifference));
if (UnderLyingDifferences.Any())
{
foreach(var d in UnderLyingDifferences)
{
var sub = d.Report(path.Concat(new[] { ObjectName }));
if (!string.IsNullOrWhiteSpace(sub))
b.AppendLine(sub);
}
}
return b.ToString();
}
}
public class NoDifference : Difference
{
public override string Report(IEnumerable<string> path)
{
return string.Empty;
}
}
public Result JsonDifferenceReport(string objectName,string expected,string actual, bool allowUnexpectedProperties, bool allowUndefinedArrayMembers)
{
if (String.IsNullOrEmpty(objectName))
throw new ArgumentNullException("objectName");
if (null == expected)
throw new ArgumentNullException("first");
if (null == actual)
throw new ArgumentNullException("second");
var expectedNode = Deserialize(expected);
var actualNode = Deserialize(actual);
return new Result
{
Report = DocumentDifference(expectedNode, actualNode, allowUnexpectedProperties, "\"root\"", allowUndefinedArrayMembers),
ActualPretty = Prettify(actual),
ExpectedPretty = Prettify(SanitizeWildcards( expected))
};
}
private bool IsNull(dynamic value)
{
return value == null;
}
private Difference ValueDifference(dynamic expectedNode, dynamic actualNode, string objectName)
{
//both null means same
if (IsNull(expectedNode) && IsNull(actualNode))
return new NoDifference();
//if expectation is "wildcard", it has no relevance if different, so we return false
if (Convert.ToString(expectedNode).Equals(SuperSecretWildcard))
return new NoDifference();
//both not null means we can compare with equals
string eValue = AsString(expectedNode);
string aValue = AsString(actualNode);
if (eValue.Equals(aValue))
return new NoDifference();
return new Difference
{
ObjectName = objectName,
DescriptionOfDifference = string.Format("Expected value '{0}' but was '{1}'", eValue, aValue)
};
}
private Difference ArrayDifference(Newtonsoft.Json.Linq.JArray expectedNode, Newtonsoft.Json.Linq.JArray actualNode, bool allowUnexpectedProperties, string objectName, bool allowUndefinedArrayMembers)
{
string immediateDiff = string.Empty;
if(!allowUndefinedArrayMembers && (expectedNode.Count != actualNode.Count))
{
return new Difference
{
ObjectName = objectName,
DescriptionOfDifference = string.Format("Array expected to have {0} members, but has {1} members", expectedNode.Count, actualNode.Count)
};
}
var diffs = new List<Difference>();
var eLooper = expectedNode.Children().GetEnumerator();
var aLooper = actualNode.Children().GetEnumerator(); ;
int idx = 0;
while (eLooper.MoveNext())
{
aLooper.MoveNext();
var e = eLooper.Current;
var a = aLooper.Current;
var eObj = e as Newtonsoft.Json.Linq.JObject;
if (eObj != null)
{
diffs.Add(ObjectDifference(eObj, a as Newtonsoft.Json.Linq.JObject, allowUnexpectedProperties, string.Format("{0}[{1}]", objectName, idx), allowUndefinedArrayMembers));
}
else
{
diffs.Add(ValueDifference(e, a, string.Format("{0}[{1}]", objectName, idx)));
}
idx++;
}
return new Difference
{
ObjectName = objectName,
UnderLyingDifferences = diffs
};
}
private Difference ObjectDifference(Newtonsoft.Json.Linq.JObject expectedNode, Newtonsoft.Json.Linq.JObject actualNode, bool allowUnexpectedProperties, string objectName, bool allowUndefinedArrayMembers)
{
//Use dictionary-trick
var jsonExpected = expectedNode.ToString(Formatting.None);
var jsonActial = actualNode.ToString(Formatting.None);
return DocumentDifference(Deserialize(jsonExpected), Deserialize(jsonActial), allowUnexpectedProperties, objectName, allowUndefinedArrayMembers);
}
private Difference DocumentDifference(Dictionary<string, dynamic> expectedNodes, Dictionary<string, dynamic> actualNodes, bool allowUnexpectedProperties, string objectName, bool allowUndefinedArrayMembers)
{
List<String> allKeys = new List<String>();
foreach (String key in expectedNodes.Keys)
if (!allKeys.Any(X => X.Equals(key))) allKeys.Add(key);
foreach (String key in actualNodes.Keys)
if (!allKeys.Any(X => X.Equals(key))) allKeys.Add(key);
List<Difference> differences = new List<Difference>();
string missing = string.Empty;
foreach (String key in allKeys)
{
if (!actualNodes.ContainsKey(key))
{
missing += string.Format("expected property missing: \"{0}\",\r\n", key);
continue;
}
if (!allowUnexpectedProperties && !expectedNodes.ContainsKey(key))
{
missing += string.Format("unexpected property in actual: \"{0}\":{1}\r\n", key, AsString(actualNodes[key]));
continue;
}
if (!expectedNodes.ContainsKey(key) && allowUnexpectedProperties)
{ continue; }
//Type match might not be necessary if it's just *somestring*
var eNode = expectedNodes[key];
if (eNode!= null && eNode.Equals(SuperSecretWildcard))
return new NoDifference();
var eType = GetTypeOfDynamic(eNode);
var aNode = actualNodes[key];
var aType = GetTypeOfDynamic(aNode);
if (NodeTypesDifferUnexpectedly(eType, aType))
{
missing += string.Format("property type mismatch: Expected '{0}' to be of type '{1}' but was of type '{2}',\r\n", key, eType, aType);
continue;
}
//////Value comparison might not be relevant if types match and value is just *some___*
if (!RequiresValueComparison(eNode))
continue;
//////Compare, based on type (Array, Object or value)
var eArray = eNode as Newtonsoft.Json.Linq.JArray;
if(eArray != null)
{
differences.Add(ArrayDifference(eArray, aNode as Newtonsoft.Json.Linq.JArray,allowUnexpectedProperties, key, allowUndefinedArrayMembers));
continue;
}
var eObj = eNode as Newtonsoft.Json.Linq.JObject;
if (eObj != null)
{
differences.Add(ObjectDifference(eObj, aNode as Newtonsoft.Json.Linq.JObject, allowUnexpectedProperties, key, allowUndefinedArrayMembers));
continue;
}
differences.Add(ValueDifference(eNode, aNode, key));
}
return new Difference
{
ObjectName = objectName,
DescriptionOfDifference = missing,
UnderLyingDifferences = differences
};
}
private bool NodeTypesDifferUnexpectedly(string expectedType, string actualType)
{
if (expectedType == SuperSecretWildcard)
return false;
return !(expectedType.Equals(actualType));
}
private bool RequiresValueComparison(dynamic node)
{
if (node == null)
return true; //expected is blank - that can only mean that it is expected to be blank
bool isAnyString = node.Equals(SuperSecretAnyString);
bool isAnyNumber = node.Equals(SuperSecretAnyNumber);
return !isAnyNumber && !isAnyString;
}
private string GetTypeOfDynamic(dynamic d)
{
if (d == null)
return "null";
if(d as string != null)
{
if (d.Equals(SuperSecretWildcard))
return WildcardType;
if (d.Equals(SuperSecretAnyNumber))
return NumberType;
//SuperSecretAnyString & all other strings
return StringType;
}
if(IsNumeric(d))
{
return NumberType;
}
return d.GetType().Name;
}
private string AsString(dynamic value)
{
if (value == null)
return "null";
if(value is string)
{
return string.Format("\"{0}\"", value);
}
return value.ToString();
}
private Dictionary<string, dynamic> Deserialize(string json)
{
var withWildcardReplaced = SanitizeWildcards(json);
var readyJson = WithToplevelAsDictionarizable(withWildcardReplaced);
//Started breaking once top level was array expresseion...
return JsonConvert.DeserializeObject<Dictionary<string, dynamic>>(readyJson);
}
private string WithToplevelAsDictionarizable(string json)
{
if (json.Trim().StartsWith("["))
return string.Format(@"{{ ""TopLevelArray"" : {0} }}", json);
return json;
}
private string Prettify(string json)
{
var x = JsonConvert.DeserializeObject(json);
return JsonConvert.SerializeObject(x, Formatting.Indented);
}
private string SanitizeWildcards(string json)
{
return json
.Replace("*somestring*", string.Format("\"{0}\"", SuperSecretWildcard))
.Replace("*somestring*", string.Format("\"{0}\"", SuperSecretAnyString))
.Replace("*somenumber*", string.Format("\"{0}\"", SuperSecretAnyNumber));
}
public static bool IsNumeric(object expression)
{
if (expression == null || expression is DateTime)
return false;
if (expression is Int16 || expression is Int32 || expression is Int64 || expression is Decimal || expression is Single || expression is Double)
return true;
try
{
if (expression is string)
Double.Parse(expression as string);
else
Double.Parse(expression.ToString());
return true;
}
catch { } // just dismiss errors but return false
return false;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment