Skip to content

Commit 8a2b582

Browse files
authored
Improve error messages for ContainSubtree (#36)
* Improve error messages for ContainSubtree * Also require strict order for elements in an array when checking subtree
1 parent abcb521 commit 8a2b582

3 files changed

Lines changed: 232 additions & 157 deletions

File tree

Src/FluentAssertions.Json/JTokenAssertions.cs

Lines changed: 30 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Diagnostics;
2-
using System.Linq;
32
using FluentAssertions.Collections;
43
using FluentAssertions.Json.Common;
54
using FluentAssertions.Execution;
@@ -86,12 +85,20 @@ public AndConstraint<JTokenAssertions> BeEquivalentTo(string expected, string be
8685
public AndConstraint<JTokenAssertions> BeEquivalentTo(JToken expected, string because = "",
8786
params object[] becauseArgs)
8887
{
89-
Difference difference = JTokenDifferentiator.FindFirstDifference(Subject, expected);
88+
return BeEquivalentTo(expected, false, because, becauseArgs);
89+
}
90+
91+
private AndConstraint<JTokenAssertions> BeEquivalentTo(JToken expected, bool ignoreExtraProperties, string because = "",
92+
params object[] becauseArgs)
93+
{
94+
Difference difference = JTokenDifferentiator.FindFirstDifference(Subject, expected, ignoreExtraProperties);
95+
96+
var expectation = ignoreExtraProperties ? "was expected to contain" : "was expected to be equivalent to";
9097

9198
var message = $"JSON document {difference?.ToString().EscapePlaceholders()}.{Environment.NewLine}" +
9299
$"Actual document{Environment.NewLine}" +
93100
$"{Format(Subject, true).EscapePlaceholders()}{Environment.NewLine}" +
94-
$"was expected to be equivalent to{Environment.NewLine}" +
101+
$"{expectation}{Environment.NewLine}" +
95102
$"{Format(expected, true).EscapePlaceholders()}{Environment.NewLine}" +
96103
"{reason}.";
97104

@@ -373,9 +380,9 @@ public AndConstraint<JTokenAssertions> HaveCount(int expected, string because =
373380
return new AndConstraint<JTokenAssertions>(this);
374381
}
375382
}
376-
383+
377384
/// <summary>
378-
/// Recursively asserts that the current <see cref="JToken"/> contains at least the properties or elements of the specified <see cref="JToken"/>.
385+
/// Recursively asserts that the current <see cref="JToken"/> contains at least the properties or elements of the specified <paramref name="substree"/>.
379386
/// </summary>
380387
/// <param name="subtree">The subtree to search for</param>
381388
/// <param name="because">
@@ -400,18 +407,27 @@ public AndConstraint<JTokenAssertions> HaveCount(int expected, string because =
400407
/// var json = JToken.Parse("{ id: 1, items: [ { id: 2, type: 'my-type', name: 'Alpha' }, { id: 3, type: 'other-type', name: 'Bravo' } ] }");
401408
/// json.Should().ContainSubtree(JToken.Parse("{ items: [ { type: 'my-type', name: 'Alpha' } ] }"));
402409
/// </code>
403-
public AndConstraint<JTokenAssertions> ContainSubtree(JToken subtree, string because = "", params object[] becauseArgs)
410+
public AndConstraint<JTokenAssertions> ContainSubtree(string subtree, string because = "", params object[] becauseArgs)
404411
{
405-
Execute.Assertion
406-
.ForCondition(JTokenContainsSubtree(Subject, subtree))
407-
.BecauseOf(because, becauseArgs)
408-
.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.
412+
JToken subtreeToken;
413+
try
414+
{
415+
subtreeToken = JToken.Parse(subtree);
416+
}
417+
catch (Exception ex)
418+
{
419+
throw new ArgumentException(
420+
$"Unable to parse expected JSON string:{Environment.NewLine}" +
421+
$"{subtree}{Environment.NewLine}" +
422+
"Check inner exception for more details.",
423+
nameof(subtree), ex);
424+
}
409425

410-
return new AndConstraint<JTokenAssertions>(this);
426+
return ContainSubtree(subtreeToken, because, becauseArgs);
411427
}
412428

413429
/// <summary>
414-
/// Recursively asserts that the current <see cref="JToken"/> contains at least the properties or elements of the specified <see cref="JToken"/>.
430+
/// Recursively asserts that the current <see cref="JToken"/> contains at least the properties or elements of the specified <paramref name="substree"/>.
415431
/// </summary>
416432
/// <param name="subtree">The subtree to search for</param>
417433
/// <param name="because">
@@ -436,62 +452,9 @@ public AndConstraint<JTokenAssertions> ContainSubtree(JToken subtree, string bec
436452
/// var json = JToken.Parse("{ id: 1, items: [ { id: 2, type: 'my-type', name: 'Alpha' }, { id: 3, type: 'other-type', name: 'Bravo' } ] }");
437453
/// json.Should().ContainSubtree(JToken.Parse("{ items: [ { type: 'my-type', name: 'Alpha' } ] }"));
438454
/// </code>
439-
public AndConstraint<JTokenAssertions> ContainSubtree(string subtree, string because = "", params object[] becauseArgs)
440-
{
441-
JToken subtreeToken;
442-
try
443-
{
444-
subtreeToken = JToken.Parse(subtree);
445-
}
446-
catch (Exception ex)
447-
{
448-
throw new ArgumentException(
449-
$"Unable to parse expected JSON string:{Environment.NewLine}" +
450-
$"{subtree}{Environment.NewLine}" +
451-
"Check inner exception for more details.",
452-
nameof(subtree), ex);
453-
}
454-
455-
return ContainSubtree(subtreeToken, because, becauseArgs);
456-
}
457-
458-
private bool JTokenContainsSubtree(JToken token, JToken subtree)
455+
public AndConstraint<JTokenAssertions> ContainSubtree(JToken subtree, string because = "", params object[] becauseArgs)
459456
{
460-
switch (subtree.Type)
461-
{
462-
case JTokenType.Object:
463-
{
464-
var sub = (JObject)subtree;
465-
var obj = token as JObject;
466-
if (obj == null)
467-
return false;
468-
foreach (var subProp in sub.Properties())
469-
{
470-
var prop = obj.Property(subProp.Name);
471-
if (prop == null)
472-
return false;
473-
if (!JTokenContainsSubtree(prop.Value, subProp.Value))
474-
return false;
475-
}
476-
return true;
477-
}
478-
case JTokenType.Array:
479-
{
480-
var sub = (JArray)subtree;
481-
var arr = token as JArray;
482-
if (arr == null)
483-
return false;
484-
foreach (var subItem in sub)
485-
{
486-
if (!arr.Any(item => JTokenContainsSubtree(item, subItem)))
487-
return false;
488-
}
489-
return true;
490-
}
491-
default:
492-
return JToken.DeepEquals(token, subtree);
493-
494-
}
457+
return BeEquivalentTo(subtree, true, because, becauseArgs);
495458
}
496459

497460
public string Format(JToken value, bool useLineBreaks = false)

Src/FluentAssertions.Json/JTokenDifferentiator.cs

Lines changed: 83 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ namespace FluentAssertions.Json
77
{
88
internal static class JTokenDifferentiator
99
{
10-
public static Difference FindFirstDifference(JToken actual, JToken expected)
10+
public static Difference FindFirstDifference(JToken actual, JToken expected, bool ignoreExtraProperties)
1111
{
1212
var path = new JPath();
1313

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

29-
return FindFirstDifference(actual, expected, path);
29+
return FindFirstDifference(actual, expected, path, ignoreExtraProperties);
3030
}
3131

32-
private static Difference FindFirstDifference(JToken actual, JToken expected, JPath path)
32+
private static Difference FindFirstDifference(JToken actual, JToken expected, JPath path, bool ignoreExtraProperties)
3333
{
3434
switch (actual)
3535
{
3636
case JArray actualArray:
37-
return FindJArrayDifference(actualArray, expected, path);
38-
case JObject actualObbject:
39-
return FindJObjectDifference(actualObbject, expected, path);
37+
return FindJArrayDifference(actualArray, expected, path, ignoreExtraProperties);
38+
case JObject actualObject:
39+
return FindJObjectDifference(actualObject, expected, path, ignoreExtraProperties);
4040
case JProperty actualProperty:
41-
return FindJPropertyDifference(actualProperty, expected, path);
41+
return FindJPropertyDifference(actualProperty, expected, path, ignoreExtraProperties);
4242
case JValue actualValue:
4343
return FindValueDifference(actualValue, expected, path);
4444
default:
4545
throw new NotSupportedException();
4646
}
4747
}
4848

49-
private static Difference FindJArrayDifference(JArray actualArray, JToken expected, JPath path)
49+
private static Difference FindJArrayDifference(JArray actualArray, JToken expected, JPath path,
50+
bool ignoreExtraProperties)
5051
{
5152
if (!(expected is JArray expectedArray))
5253
{
5354
return new Difference(DifferenceKind.OtherType, path, Describe(actualArray.Type), Describe(expected.Type));
5455
}
55-
56-
return CompareItems(actualArray, expectedArray, path);
56+
57+
if (ignoreExtraProperties)
58+
{
59+
return CompareExpectedItems(actualArray, expectedArray, path);
60+
}
61+
else
62+
{
63+
return CompareItems(actualArray, expectedArray, path);
64+
}
65+
}
66+
67+
private static Difference CompareExpectedItems(JArray actual, JArray expected, JPath path)
68+
{
69+
JToken[] actualChildren = actual.Children().ToArray();
70+
JToken[] expectedChildren = expected.Children().ToArray();
71+
72+
int matchingIndex = 0;
73+
for (int expectedIndex = 0; expectedIndex < expectedChildren.Length; expectedIndex++)
74+
{
75+
var expectedChild = expectedChildren[expectedIndex];
76+
bool match = false;
77+
for (int actualIndex = matchingIndex; actualIndex < actualChildren.Length; actualIndex++)
78+
{
79+
var difference = FindFirstDifference(actualChildren[actualIndex], expectedChild, true);
80+
81+
if (difference == null)
82+
{
83+
match = true;
84+
matchingIndex = actualIndex + 1;
85+
break;
86+
}
87+
}
88+
89+
if (!match)
90+
{
91+
if (matchingIndex >= actualChildren.Length)
92+
{
93+
if (actualChildren.Any(actualChild => FindFirstDifference(actualChild, expectedChild, true) == null))
94+
{
95+
return new Difference(DifferenceKind.WrongOrder, path.AddIndex(expectedIndex));
96+
}
97+
98+
return new Difference(DifferenceKind.ActualMissesElement, path.AddIndex(expectedIndex));
99+
}
100+
101+
return FindFirstDifference(actualChildren[matchingIndex], expectedChild,
102+
path.AddIndex(expectedIndex), true);
103+
}
104+
}
105+
106+
return null;
57107
}
58108

59109
private static Difference CompareItems(JArray actual, JArray expected, JPath path)
60110
{
61-
JEnumerable<JToken> actualChildren = actual.Children();
62-
JEnumerable<JToken> expectedChildren = expected.Children();
111+
JToken[] actualChildren = actual.Children().ToArray();
112+
JToken[] expectedChildren = expected.Children().ToArray();
63113

64-
if (actualChildren.Count() != expectedChildren.Count())
114+
if (actualChildren.Length != expectedChildren.Length)
65115
{
66-
return new Difference(DifferenceKind.DifferentLength, path, actualChildren.Count(), expectedChildren.Count());
116+
return new Difference(DifferenceKind.DifferentLength, path, actualChildren.Length, expectedChildren.Length);
67117
}
68118

69-
for (int i = 0; i < actualChildren.Count(); i++)
119+
for (int i = 0; i < actualChildren.Length; i++)
70120
{
71-
Difference firstDifference = FindFirstDifference(actualChildren.ElementAt(i), expectedChildren.ElementAt(i),
72-
path.AddIndex(i));
121+
Difference firstDifference = FindFirstDifference(actualChildren[i], expectedChildren[i],
122+
path.AddIndex(i), false);
73123

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

83-
private static Difference FindJObjectDifference(JObject actual, JToken expected, JPath path)
133+
private static Difference FindJObjectDifference(JObject actual, JToken expected, JPath path, bool ignoreExtraProperties)
84134
{
85135
if (!(expected is JObject expectedObject))
86136
{
87137
return new Difference(DifferenceKind.OtherType, path, Describe(actual.Type), Describe(expected.Type));
88138
}
89139

90-
return CompareProperties(actual?.Properties(), expectedObject.Properties(), path);
140+
return CompareProperties(actual?.Properties(), expectedObject.Properties(), path, ignoreExtraProperties);
91141
}
92142

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

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

118169
Difference firstDifference = FindFirstDifference(actualValue, expectedPair.Value,
119-
path.AddProperty(expectedPair.Key));
170+
path.AddProperty(expectedPair.Key), ignoreExtraProperties);
120171

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

130-
private static Difference FindJPropertyDifference(JProperty actualProperty, JToken expected, JPath path)
181+
private static Difference FindJPropertyDifference(JProperty actualProperty, JToken expected, JPath path,
182+
bool ignoreExtraProperties)
131183
{
132184
if (!(expected is JProperty expectedProperty))
133185
{
@@ -139,7 +191,7 @@ private static Difference FindJPropertyDifference(JProperty actualProperty, JTok
139191
return new Difference(DifferenceKind.OtherName, path);
140192
}
141193

142-
return FindFirstDifference(actualProperty.Value, expectedProperty.Value, path);
194+
return FindFirstDifference(actualProperty.Value, expectedProperty.Value, path, ignoreExtraProperties);
143195
}
144196

145197
private static Difference FindValueDifference(JValue actualValue, JToken expected, JPath path)
@@ -252,6 +304,10 @@ public override string ToString()
252304
return $"misses property {Path}";
253305
case DifferenceKind.ExpectedMissesProperty:
254306
return $"has extra property {Path}";
307+
case DifferenceKind.ActualMissesElement:
308+
return $"misses expected element {Path}";
309+
case DifferenceKind.WrongOrder:
310+
return $"has expected element {Path} in the wrong order";
255311
default:
256312
throw new ArgumentOutOfRangeException();
257313
}
@@ -298,6 +354,8 @@ internal enum DifferenceKind
298354
OtherValue,
299355
DifferentLength,
300356
ActualMissesProperty,
301-
ExpectedMissesProperty
357+
ExpectedMissesProperty,
358+
ActualMissesElement,
359+
WrongOrder
302360
}
303361
}

0 commit comments

Comments
 (0)