Skip to content

Commit

Permalink
Improve error messages for ContainSubtree (#36)
Browse files Browse the repository at this point in the history
* Improve error messages for ContainSubtree
* Also require strict order for elements in an array when checking subtree
  • Loading branch information
ronaldkroon committed Mar 3, 2020
1 parent abcb521 commit 8a2b582
Show file tree
Hide file tree
Showing 3 changed files with 232 additions and 157 deletions.
97 changes: 30 additions & 67 deletions Src/FluentAssertions.Json/JTokenAssertions.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System.Diagnostics;
using System.Linq;
using FluentAssertions.Collections;
using FluentAssertions.Json.Common;
using FluentAssertions.Execution;
Expand Down Expand Up @@ -86,12 +85,20 @@ public JTokenAssertions(JToken subject)
public AndConstraint<JTokenAssertions> BeEquivalentTo(JToken expected, string because = "",
params object[] becauseArgs)
{
Difference difference = JTokenDifferentiator.FindFirstDifference(Subject, expected);
return BeEquivalentTo(expected, false, because, becauseArgs);
}

private AndConstraint<JTokenAssertions> BeEquivalentTo(JToken expected, bool ignoreExtraProperties, string because = "",
params object[] becauseArgs)
{
Difference difference = JTokenDifferentiator.FindFirstDifference(Subject, expected, ignoreExtraProperties);

var expectation = ignoreExtraProperties ? "was expected to contain" : "was expected to be equivalent to";

var message = $"JSON document {difference?.ToString().EscapePlaceholders()}.{Environment.NewLine}" +
$"Actual document{Environment.NewLine}" +
$"{Format(Subject, true).EscapePlaceholders()}{Environment.NewLine}" +
$"was expected to be equivalent to{Environment.NewLine}" +
$"{expectation}{Environment.NewLine}" +
$"{Format(expected, true).EscapePlaceholders()}{Environment.NewLine}" +
"{reason}.";

Expand Down Expand Up @@ -373,9 +380,9 @@ public AndConstraint<JTokenAssertions> HaveCount(int expected, string because =
return new AndConstraint<JTokenAssertions>(this);
}
}

/// <summary>
/// Recursively asserts that the current <see cref="JToken"/> contains at least the properties or elements of the specified <see cref="JToken"/>.
/// Recursively asserts that the current <see cref="JToken"/> contains at least the properties or elements of the specified <paramref name="substree"/>.
/// </summary>
/// <param name="subtree">The subtree to search for</param>
/// <param name="because">
Expand All @@ -400,18 +407,27 @@ public AndConstraint<JTokenAssertions> HaveCount(int expected, string because =
/// var json = JToken.Parse("{ id: 1, items: [ { id: 2, type: 'my-type', name: 'Alpha' }, { id: 3, type: 'other-type', name: 'Bravo' } ] }");
/// json.Should().ContainSubtree(JToken.Parse("{ items: [ { type: 'my-type', name: 'Alpha' } ] }"));
/// </code>
public AndConstraint<JTokenAssertions> ContainSubtree(JToken subtree, string because = "", params object[] becauseArgs)
public AndConstraint<JTokenAssertions> ContainSubtree(string subtree, string because = "", params object[] becauseArgs)
{
Execute.Assertion
.ForCondition(JTokenContainsSubtree(Subject, subtree))
.BecauseOf(because, becauseArgs)
.FailWith("Expected JSON document to contain subtree {0} {reason}, but some elements were missing.", subtree); // todo: report exact cause of failure, eg. name of the missing property, etc.
JToken subtreeToken;
try
{
subtreeToken = JToken.Parse(subtree);
}
catch (Exception ex)
{
throw new ArgumentException(
$"Unable to parse expected JSON string:{Environment.NewLine}" +
$"{subtree}{Environment.NewLine}" +
"Check inner exception for more details.",
nameof(subtree), ex);
}

return new AndConstraint<JTokenAssertions>(this);
return ContainSubtree(subtreeToken, because, becauseArgs);
}

/// <summary>
/// Recursively asserts that the current <see cref="JToken"/> contains at least the properties or elements of the specified <see cref="JToken"/>.
/// Recursively asserts that the current <see cref="JToken"/> contains at least the properties or elements of the specified <paramref name="substree"/>.
/// </summary>
/// <param name="subtree">The subtree to search for</param>
/// <param name="because">
Expand All @@ -436,62 +452,9 @@ public AndConstraint<JTokenAssertions> ContainSubtree(JToken subtree, string bec
/// var json = JToken.Parse("{ id: 1, items: [ { id: 2, type: 'my-type', name: 'Alpha' }, { id: 3, type: 'other-type', name: 'Bravo' } ] }");
/// json.Should().ContainSubtree(JToken.Parse("{ items: [ { type: 'my-type', name: 'Alpha' } ] }"));
/// </code>
public AndConstraint<JTokenAssertions> ContainSubtree(string subtree, string because = "", params object[] becauseArgs)
{
JToken subtreeToken;
try
{
subtreeToken = JToken.Parse(subtree);
}
catch (Exception ex)
{
throw new ArgumentException(
$"Unable to parse expected JSON string:{Environment.NewLine}" +
$"{subtree}{Environment.NewLine}" +
"Check inner exception for more details.",
nameof(subtree), ex);
}

return ContainSubtree(subtreeToken, because, becauseArgs);
}

private bool JTokenContainsSubtree(JToken token, JToken subtree)
public AndConstraint<JTokenAssertions> ContainSubtree(JToken subtree, string because = "", params object[] becauseArgs)
{
switch (subtree.Type)
{
case JTokenType.Object:
{
var sub = (JObject)subtree;
var obj = token as JObject;
if (obj == null)
return false;
foreach (var subProp in sub.Properties())
{
var prop = obj.Property(subProp.Name);
if (prop == null)
return false;
if (!JTokenContainsSubtree(prop.Value, subProp.Value))
return false;
}
return true;
}
case JTokenType.Array:
{
var sub = (JArray)subtree;
var arr = token as JArray;
if (arr == null)
return false;
foreach (var subItem in sub)
{
if (!arr.Any(item => JTokenContainsSubtree(item, subItem)))
return false;
}
return true;
}
default:
return JToken.DeepEquals(token, subtree);

}
return BeEquivalentTo(subtree, true, because, becauseArgs);
}

public string Format(JToken value, bool useLineBreaks = false)
Expand Down
108 changes: 83 additions & 25 deletions Src/FluentAssertions.Json/JTokenDifferentiator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace FluentAssertions.Json
{
internal static class JTokenDifferentiator
{
public static Difference FindFirstDifference(JToken actual, JToken expected)
public static Difference FindFirstDifference(JToken actual, JToken expected, bool ignoreExtraProperties)
{
var path = new JPath();

Expand All @@ -26,50 +26,100 @@ public static Difference FindFirstDifference(JToken actual, JToken expected)
return new Difference(DifferenceKind.ExpectedIsNull, path);
}

return FindFirstDifference(actual, expected, path);
return FindFirstDifference(actual, expected, path, ignoreExtraProperties);
}

private static Difference FindFirstDifference(JToken actual, JToken expected, JPath path)
private static Difference FindFirstDifference(JToken actual, JToken expected, JPath path, bool ignoreExtraProperties)
{
switch (actual)
{
case JArray actualArray:
return FindJArrayDifference(actualArray, expected, path);
case JObject actualObbject:
return FindJObjectDifference(actualObbject, expected, path);
return FindJArrayDifference(actualArray, expected, path, ignoreExtraProperties);
case JObject actualObject:
return FindJObjectDifference(actualObject, expected, path, ignoreExtraProperties);
case JProperty actualProperty:
return FindJPropertyDifference(actualProperty, expected, path);
return FindJPropertyDifference(actualProperty, expected, path, ignoreExtraProperties);
case JValue actualValue:
return FindValueDifference(actualValue, expected, path);
default:
throw new NotSupportedException();
}
}

private static Difference FindJArrayDifference(JArray actualArray, JToken expected, JPath path)
private static Difference FindJArrayDifference(JArray actualArray, JToken expected, JPath path,
bool ignoreExtraProperties)
{
if (!(expected is JArray expectedArray))
{
return new Difference(DifferenceKind.OtherType, path, Describe(actualArray.Type), Describe(expected.Type));
}

return CompareItems(actualArray, expectedArray, path);

if (ignoreExtraProperties)
{
return CompareExpectedItems(actualArray, expectedArray, path);
}
else
{
return CompareItems(actualArray, expectedArray, path);
}
}

private static Difference CompareExpectedItems(JArray actual, JArray expected, JPath path)
{
JToken[] actualChildren = actual.Children().ToArray();
JToken[] expectedChildren = expected.Children().ToArray();

int matchingIndex = 0;
for (int expectedIndex = 0; expectedIndex < expectedChildren.Length; expectedIndex++)
{
var expectedChild = expectedChildren[expectedIndex];
bool match = false;
for (int actualIndex = matchingIndex; actualIndex < actualChildren.Length; actualIndex++)
{
var difference = FindFirstDifference(actualChildren[actualIndex], expectedChild, true);

if (difference == null)
{
match = true;
matchingIndex = actualIndex + 1;
break;
}
}

if (!match)
{
if (matchingIndex >= actualChildren.Length)
{
if (actualChildren.Any(actualChild => FindFirstDifference(actualChild, expectedChild, true) == null))
{
return new Difference(DifferenceKind.WrongOrder, path.AddIndex(expectedIndex));
}

return new Difference(DifferenceKind.ActualMissesElement, path.AddIndex(expectedIndex));
}

return FindFirstDifference(actualChildren[matchingIndex], expectedChild,
path.AddIndex(expectedIndex), true);
}
}

return null;
}

private static Difference CompareItems(JArray actual, JArray expected, JPath path)
{
JEnumerable<JToken> actualChildren = actual.Children();
JEnumerable<JToken> expectedChildren = expected.Children();
JToken[] actualChildren = actual.Children().ToArray();
JToken[] expectedChildren = expected.Children().ToArray();

if (actualChildren.Count() != expectedChildren.Count())
if (actualChildren.Length != expectedChildren.Length)
{
return new Difference(DifferenceKind.DifferentLength, path, actualChildren.Count(), expectedChildren.Count());
return new Difference(DifferenceKind.DifferentLength, path, actualChildren.Length, expectedChildren.Length);
}

for (int i = 0; i < actualChildren.Count(); i++)
for (int i = 0; i < actualChildren.Length; i++)
{
Difference firstDifference = FindFirstDifference(actualChildren.ElementAt(i), expectedChildren.ElementAt(i),
path.AddIndex(i));
Difference firstDifference = FindFirstDifference(actualChildren[i], expectedChildren[i],
path.AddIndex(i), false);

if (firstDifference != null)
{
Expand All @@ -80,17 +130,18 @@ private static Difference CompareItems(JArray actual, JArray expected, JPath pat
return null;
}

private static Difference FindJObjectDifference(JObject actual, JToken expected, JPath path)
private static Difference FindJObjectDifference(JObject actual, JToken expected, JPath path, bool ignoreExtraProperties)
{
if (!(expected is JObject expectedObject))
{
return new Difference(DifferenceKind.OtherType, path, Describe(actual.Type), Describe(expected.Type));
}

return CompareProperties(actual?.Properties(), expectedObject.Properties(), path);
return CompareProperties(actual?.Properties(), expectedObject.Properties(), path, ignoreExtraProperties);
}

private static Difference CompareProperties(IEnumerable<JProperty> actual, IEnumerable<JProperty> expected, JPath path)
private static Difference CompareProperties(IEnumerable<JProperty> actual, IEnumerable<JProperty> expected, JPath path,
bool ignoreExtraProperties)
{
var actualDictionary = actual?.ToDictionary(p => p.Name, p => p.Value) ?? new Dictionary<string, JToken>();
var expectedDictionary = expected?.ToDictionary(p => p.Name, p => p.Value) ?? new Dictionary<string, JToken>();
Expand All @@ -105,7 +156,7 @@ private static Difference CompareProperties(IEnumerable<JProperty> actual, IEnum

foreach (KeyValuePair<string, JToken> actualPair in actualDictionary)
{
if (!expectedDictionary.ContainsKey(actualPair.Key))
if (!ignoreExtraProperties && !expectedDictionary.ContainsKey(actualPair.Key))
{
return new Difference(DifferenceKind.ExpectedMissesProperty, path.AddProperty(actualPair.Key));
}
Expand All @@ -116,7 +167,7 @@ private static Difference CompareProperties(IEnumerable<JProperty> actual, IEnum
JToken actualValue = actualDictionary[expectedPair.Key];

Difference firstDifference = FindFirstDifference(actualValue, expectedPair.Value,
path.AddProperty(expectedPair.Key));
path.AddProperty(expectedPair.Key), ignoreExtraProperties);

if (firstDifference != null)
{
Expand All @@ -127,7 +178,8 @@ private static Difference CompareProperties(IEnumerable<JProperty> actual, IEnum
return null;
}

private static Difference FindJPropertyDifference(JProperty actualProperty, JToken expected, JPath path)
private static Difference FindJPropertyDifference(JProperty actualProperty, JToken expected, JPath path,
bool ignoreExtraProperties)
{
if (!(expected is JProperty expectedProperty))
{
Expand All @@ -139,7 +191,7 @@ private static Difference FindJPropertyDifference(JProperty actualProperty, JTok
return new Difference(DifferenceKind.OtherName, path);
}

return FindFirstDifference(actualProperty.Value, expectedProperty.Value, path);
return FindFirstDifference(actualProperty.Value, expectedProperty.Value, path, ignoreExtraProperties);
}

private static Difference FindValueDifference(JValue actualValue, JToken expected, JPath path)
Expand Down Expand Up @@ -252,6 +304,10 @@ public override string ToString()
return $"misses property {Path}";
case DifferenceKind.ExpectedMissesProperty:
return $"has extra property {Path}";
case DifferenceKind.ActualMissesElement:
return $"misses expected element {Path}";
case DifferenceKind.WrongOrder:
return $"has expected element {Path} in the wrong order";
default:
throw new ArgumentOutOfRangeException();
}
Expand Down Expand Up @@ -298,6 +354,8 @@ internal enum DifferenceKind
OtherValue,
DifferentLength,
ActualMissesProperty,
ExpectedMissesProperty
ExpectedMissesProperty,
ActualMissesElement,
WrongOrder
}
}

0 comments on commit 8a2b582

Please sign in to comment.