Skip to content

Commit fa7de19

Browse files
Added the option to ignore the order in arrays and collections (#80)
1 parent 0606d9a commit fa7de19

File tree

9 files changed

+372
-3
lines changed

9 files changed

+372
-3
lines changed

README.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,4 +56,22 @@ var expected = JToken.Parse(@"{ ""value"" : 1.4 }");
5656
actual.Should().BeEquivalentTo(expected, options => options
5757
.Using<double>(d => d.Subject.Should().BeApproximately(d.Expectation, 0.1))
5858
.WhenTypeIs<double>());
59-
```
59+
```
60+
61+
Also, there is `WithoutStrictOrdering()` which allows you to compare JSON arrays while ignoring the order of their elements.
62+
This is useful when the sequence of items is not important for your test scenario. When applied, assertions like `BeEquivalentTo()` will
63+
succeed as long as the arrays contain the same elements, regardless of their order.
64+
65+
Example:
66+
67+
```c#
68+
using FluentAssertions;
69+
using FluentAssertions.Json;
70+
using Newtonsoft.Json.Linq;
71+
72+
...
73+
var actual = JToken.Parse(@"{ ""array"" : [1, 2, 3] }");
74+
var expected = JToken.Parse(@"{ ""array"" : [3, 2, 1] }");
75+
actual.Should().BeEquivalentTo(expected, options => options
76+
.WithoutStrictOrdering());
77+
```
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using Newtonsoft.Json.Linq;
5+
6+
namespace FluentAssertions.Json.Common
7+
{
8+
internal static class JTokenExtensions
9+
{
10+
private static readonly JTokenComparer Comparer = new();
11+
12+
/// <summary>
13+
/// Recursively sorts the properties of JObject instances by name and
14+
/// the elements of JArray instances by their string representation,
15+
/// producing a normalized JToken for consistent comparison
16+
/// </summary>
17+
public static JToken Normalize(this JToken token)
18+
{
19+
return token switch
20+
{
21+
JObject obj => new JObject(obj.Properties().OrderBy(p => p.Name).Select(p => new JProperty(p.Name, Normalize(p.Value)))),
22+
JArray array => new JArray(array.Select(Normalize).OrderBy(x => x, Comparer)),
23+
_ => token
24+
};
25+
}
26+
27+
private sealed class JTokenComparer : IComparer<JToken>
28+
{
29+
public int Compare(JToken x, JToken y)
30+
{
31+
if (ReferenceEquals(x, y))
32+
return 0;
33+
34+
if (x is null)
35+
return -1;
36+
37+
if (y is null)
38+
return 1;
39+
40+
var typeComparison = x.Type.CompareTo(y.Type);
41+
if (typeComparison != 0)
42+
return typeComparison;
43+
44+
return x switch
45+
{
46+
JArray a => Compare(a, (JArray)y),
47+
JObject o => Compare(o, (JObject)y),
48+
JProperty p => Compare(p, (JProperty)y),
49+
JValue v => Compare(v, (JValue)y),
50+
JConstructor c => Compare(c, (JConstructor)y),
51+
_ => string.Compare(x.ToString(), y.ToString(), StringComparison.Ordinal)
52+
};
53+
}
54+
55+
private static int Compare(JValue x, JValue y) => Comparer<object>.Default.Compare(x.Value, y.Value);
56+
57+
private static int Compare(JConstructor x, JConstructor y)
58+
{
59+
var nameComparison = string.Compare(x.Name, y.Name, StringComparison.Ordinal);
60+
return nameComparison != 0 ? nameComparison : Compare(x, (JContainer)y);
61+
}
62+
63+
private static int Compare(JContainer x, JContainer y)
64+
{
65+
var countComparison = x.Count.CompareTo(y.Count);
66+
if (countComparison != 0)
67+
return countComparison;
68+
69+
return x
70+
.Select((t, i) => Comparer.Compare(t, y[i]))
71+
.FirstOrDefault(itemComparison => itemComparison != 0);
72+
}
73+
74+
private static int Compare(JObject x, JObject y)
75+
{
76+
var countComparison = x.Count.CompareTo(y.Count);
77+
if (countComparison != 0)
78+
return countComparison;
79+
80+
return x.Properties()
81+
.OrderBy(p => p.Name)
82+
.Zip(y.Properties().OrderBy(p => p.Name), (px, py) => Compare(px, py))
83+
.FirstOrDefault(itemComparison => itemComparison != 0);
84+
}
85+
86+
private static int Compare(JProperty x, JProperty y)
87+
{
88+
var nameComparison = string.Compare(x.Name, y.Name, StringComparison.Ordinal);
89+
return nameComparison != 0 ? nameComparison : Comparer.Compare(x.Value, y.Value);
90+
}
91+
}
92+
}
93+
}

Src/FluentAssertions.Json/IJsonAssertionOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,10 @@ public interface IJsonAssertionOptions<T>
1616
/// The assertion to execute when the predicate is met.
1717
/// </param>
1818
IJsonAssertionRestriction<T, TProperty> Using<TProperty>(Action<IAssertionContext<TProperty>> action);
19+
20+
/// <summary>
21+
/// Configures the JSON assertion to ignore the order of elements in arrays or collections during comparison, allowing for equivalency checks regardless of element sequence.
22+
/// </summary>
23+
IJsonAssertionOptions<T> WithoutStrictOrdering();
1924
}
2025
}

Src/FluentAssertions.Json/JTokenDifferentiator.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Collections.Generic;
33
using System.Linq;
44
using FluentAssertions.Execution;
5+
using FluentAssertions.Json.Common;
56
using Newtonsoft.Json.Linq;
67

78
namespace FluentAssertions.Json
@@ -11,12 +12,14 @@ internal class JTokenDifferentiator
1112
private readonly bool ignoreExtraProperties;
1213

1314
private readonly Func<IJsonAssertionOptions<object>, IJsonAssertionOptions<object>> config;
15+
private readonly JsonAssertionOptions<object> options;
1416

1517
public JTokenDifferentiator(bool ignoreExtraProperties,
1618
Func<IJsonAssertionOptions<object>, IJsonAssertionOptions<object>> config)
1719
{
1820
this.ignoreExtraProperties = ignoreExtraProperties;
1921
this.config = config;
22+
this.options = (JsonAssertionOptions<object>)config(new JsonAssertionOptions<object>());
2023
}
2124

2225
public Difference FindFirstDifference(JToken actual, JToken expected)
@@ -38,6 +41,12 @@ public Difference FindFirstDifference(JToken actual, JToken expected)
3841
return new Difference(DifferenceKind.ExpectedIsNull, path);
3942
}
4043

44+
if (!options.IsStrictlyOrdered)
45+
{
46+
actual = actual.Normalize();
47+
expected = expected.Normalize();
48+
}
49+
4150
return FindFirstDifference(actual, expected, path);
4251
}
4352

Src/FluentAssertions.Json/JsonAssertionOptions.cs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,27 @@
44
namespace FluentAssertions.Json
55
{
66
/// <summary>
7-
/// Represents the run-time type-specific behavior of a JSON structural equivalency assertion. It is the equivalent of <see cref="FluentAssertions.Equivalency.EquivalencyAssertionOptions{T}"/>
7+
/// Represents the run-time type-specific behavior of a JSON structural equivalency assertion. It is the equivalent of <see cref="FluentAssertions.Equivalency.EquivalencyOptions{T}"/>
88
/// </summary>
99
public sealed class JsonAssertionOptions<T> : EquivalencyOptions<T>, IJsonAssertionOptions<T>
1010
{
11+
internal JsonAssertionOptions() { }
12+
1113
public JsonAssertionOptions(EquivalencyOptions<T> equivalencyAssertionOptions) : base(equivalencyAssertionOptions)
1214
{
13-
1415
}
16+
17+
internal bool IsStrictlyOrdered { get; private set; } = true;
18+
1519
public new IJsonAssertionRestriction<T, TProperty> Using<TProperty>(Action<IAssertionContext<TProperty>> action)
1620
{
1721
return new JsonAssertionRestriction<T, TProperty>(base.Using(action));
1822
}
23+
24+
public new IJsonAssertionOptions<T> WithoutStrictOrdering()
25+
{
26+
IsStrictlyOrdered = false;
27+
return this;
28+
}
1929
}
2030
}

Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/net47.verified.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ namespace FluentAssertions.Json
44
public interface IJsonAssertionOptions<T>
55
{
66
FluentAssertions.Json.IJsonAssertionRestriction<T, TProperty> Using<TProperty>(System.Action<FluentAssertions.Equivalency.IAssertionContext<TProperty>> action);
7+
FluentAssertions.Json.IJsonAssertionOptions<T> WithoutStrictOrdering();
78
}
89
public interface IJsonAssertionRestriction<T, TMember>
910
{
@@ -50,6 +51,7 @@ namespace FluentAssertions.Json
5051
{
5152
public JsonAssertionOptions(FluentAssertions.Equivalency.EquivalencyOptions<T> equivalencyAssertionOptions) { }
5253
public FluentAssertions.Json.IJsonAssertionRestriction<T, TProperty> Using<TProperty>(System.Action<FluentAssertions.Equivalency.IAssertionContext<TProperty>> action) { }
54+
public FluentAssertions.Json.IJsonAssertionOptions<T> WithoutStrictOrdering() { }
5355
}
5456
public sealed class JsonAssertionRestriction<T, TProperty> : FluentAssertions.Json.IJsonAssertionRestriction<T, TProperty>
5557
{

Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/netstandard2.0.verified.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ namespace FluentAssertions.Json
44
public interface IJsonAssertionOptions<T>
55
{
66
FluentAssertions.Json.IJsonAssertionRestriction<T, TProperty> Using<TProperty>(System.Action<FluentAssertions.Equivalency.IAssertionContext<TProperty>> action);
7+
FluentAssertions.Json.IJsonAssertionOptions<T> WithoutStrictOrdering();
78
}
89
public interface IJsonAssertionRestriction<T, TMember>
910
{
@@ -50,6 +51,7 @@ namespace FluentAssertions.Json
5051
{
5152
public JsonAssertionOptions(FluentAssertions.Equivalency.EquivalencyOptions<T> equivalencyAssertionOptions) { }
5253
public FluentAssertions.Json.IJsonAssertionRestriction<T, TProperty> Using<TProperty>(System.Action<FluentAssertions.Equivalency.IAssertionContext<TProperty>> action) { }
54+
public FluentAssertions.Json.IJsonAssertionOptions<T> WithoutStrictOrdering() { }
5355
}
5456
public sealed class JsonAssertionRestriction<T, TProperty> : FluentAssertions.Json.IJsonAssertionRestriction<T, TProperty>
5557
{
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using Newtonsoft.Json.Linq;
4+
using Xunit;
5+
6+
namespace FluentAssertions.Json.Specs
7+
{
8+
public class JTokenComparerSpecs
9+
{
10+
private static readonly IComparer<JToken> Comparer =
11+
Type.GetType("FluentAssertions.Json.Common.JTokenExtensions, FluentAssertions.Json")!
12+
.GetField("Comparer", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)!
13+
.GetValue(null) as IComparer<JToken>;
14+
15+
[Fact]
16+
public void Should_return_zero_for_same_reference()
17+
{
18+
// Arrange
19+
var token = JToken.Parse(@"{""a"":1}");
20+
21+
// Act & Assert
22+
Comparer.Compare(token, token).Should().Be(0);
23+
}
24+
25+
[Fact]
26+
public void Should_handle_nulls()
27+
{
28+
// Arrange
29+
var token = JToken.Parse("1");
30+
31+
// Act & Assert
32+
Comparer.Compare(null, token).Should().Be(-1);
33+
Comparer.Compare(token, null).Should().Be(1);
34+
Comparer.Compare(null, null).Should().Be(0);
35+
}
36+
37+
[Fact]
38+
public void Should_compare_different_types()
39+
{
40+
// Arrange
41+
var obj = JToken.Parse(@"{""a"":1}");
42+
var arr = JToken.Parse("[1]");
43+
44+
// Act & Assert
45+
Comparer.Compare(obj, arr).Should().NotBe(0);
46+
}
47+
48+
[Fact]
49+
public void Should_compare_jvalues()
50+
{
51+
// Arrange
52+
var v1 = new JValue(1);
53+
var v2 = new JValue(2);
54+
55+
// Act & Assert
56+
Comparer.Compare(v1, v2).Should().Be(-1);
57+
Comparer.Compare(v2, v1).Should().Be(1);
58+
Comparer.Compare(v1, new JValue(1)).Should().Be(0);
59+
}
60+
61+
[Fact]
62+
public void Should_compare_jarrays_by_count_and_elements()
63+
{
64+
// Arrange
65+
var arr1 = JArray.Parse("[1,2]");
66+
var arr2 = JArray.Parse("[1,2,3]");
67+
var arr3 = JArray.Parse("[1,3]");
68+
var arr4 = JArray.Parse("[1,2,3]");
69+
70+
// Act & Assert
71+
Comparer.Compare(arr1, arr2).Should().Be(-1);
72+
Comparer.Compare(arr1, arr3).Should().Be(-1);
73+
Comparer.Compare(arr3, arr1).Should().Be(1);
74+
Comparer.Compare(arr2, arr4).Should().Be(0);
75+
}
76+
77+
[Fact]
78+
public void Should_compare_jobjects_by_count_and_properties()
79+
{
80+
// Arrange
81+
var obj1 = JObject.Parse(@"{""a"":1}");
82+
var obj2 = JObject.Parse(@"{""a"":1,""b"":2}");
83+
var obj3 = JObject.Parse(@"{""a"":2}");
84+
var obj4 = JObject.Parse(@"{""a"":1,""b"":2}");
85+
var obj5 = JObject.Parse(@"{""b"":2}");
86+
87+
// Act & Assert
88+
Comparer.Compare(obj1, obj2).Should().Be(-1);
89+
Comparer.Compare(obj1, obj3).Should().Be(-1);
90+
Comparer.Compare(obj3, obj1).Should().Be(1);
91+
Comparer.Compare(obj2, obj4).Should().Be(0);
92+
Comparer.Compare(obj1, obj5).Should().Be(-1);
93+
Comparer.Compare(obj5, obj1).Should().Be(1);
94+
}
95+
96+
[Fact]
97+
public void Should_compare_jproperties_by_name_and_value()
98+
{
99+
// Arrange
100+
var prop1 = new JProperty("a", 1);
101+
var prop2 = new JProperty("b", 1);
102+
var prop3 = new JProperty("a", 2);
103+
var prop4 = new JProperty("a", 1);
104+
105+
// Act & Assert
106+
Comparer.Compare(prop1, prop2).Should().Be(-1);
107+
Comparer.Compare(prop1, prop3).Should().Be(-1);
108+
Comparer.Compare(prop3, prop1).Should().Be(1);
109+
Comparer.Compare(prop4, prop1).Should().Be(0);
110+
Comparer.Compare(prop2, prop3).Should().Be(1);
111+
Comparer.Compare(prop3, prop2).Should().Be(-1);
112+
}
113+
114+
[Fact]
115+
public void Should_compare_jconstructors_by_name()
116+
{
117+
// Arrange
118+
var ctor1 = new JConstructor("foo", new JValue(1));
119+
var ctor2 = new JConstructor("bar", new JValue(1));
120+
121+
// Act & Assert
122+
Comparer.Compare(ctor1, ctor2).Should().BeGreaterThan(0); // "foo" > "bar"
123+
}
124+
125+
[Fact]
126+
public void Should_compare_jconstructors_by_argument_count()
127+
{
128+
// Arrange
129+
var ctor1 = new JConstructor("foo", new JValue(1));
130+
var ctor2 = new JConstructor("foo", new JValue(1), new JValue(2));
131+
132+
// Act & Assert
133+
Comparer.Compare(ctor1, ctor2).Should().Be(-1);
134+
}
135+
136+
[Fact]
137+
public void Should_compare_jconstructors_by_argument_values()
138+
{
139+
// Arrange
140+
var ctor1 = new JConstructor("foo", new JValue(1), new JValue(2));
141+
var ctor2 = new JConstructor("foo", new JValue(1), new JValue(3));
142+
143+
// Act & Assert
144+
Comparer.Compare(ctor1, ctor2).Should().Be(-1);
145+
}
146+
147+
[Fact]
148+
public void Should_return_zero_for_equal_jconstructors()
149+
{
150+
// Arrange
151+
var ctor1 = new JConstructor("foo", new JValue(1), new JValue(2));
152+
var ctor2 = new JConstructor("foo", new JValue(1), new JValue(2));
153+
154+
// Act & Assert
155+
Comparer.Compare(ctor1, ctor2).Should().Be(0);
156+
}
157+
}
158+
}

0 commit comments

Comments
 (0)