diff --git a/README.md b/README.md index 1da4d759..68fec189 100644 --- a/README.md +++ b/README.md @@ -56,4 +56,22 @@ var expected = JToken.Parse(@"{ ""value"" : 1.4 }"); actual.Should().BeEquivalentTo(expected, options => options .Using(d => d.Subject.Should().BeApproximately(d.Expectation, 0.1)) .WhenTypeIs()); -``` \ No newline at end of file +``` + +Also, there is `WithoutStrictOrdering()` which allows you to compare JSON arrays while ignoring the order of their elements. +This is useful when the sequence of items is not important for your test scenario. When applied, assertions like `BeEquivalentTo()` will +succeed as long as the arrays contain the same elements, regardless of their order. + +Example: + +```c# +using FluentAssertions; +using FluentAssertions.Json; +using Newtonsoft.Json.Linq; + +... +var actual = JToken.Parse(@"{ ""array"" : [1, 2, 3] }"); +var expected = JToken.Parse(@"{ ""array"" : [3, 2, 1] }"); +actual.Should().BeEquivalentTo(expected, options => options + .WithoutStrictOrdering()); +``` diff --git a/Src/FluentAssertions.Json/Common/JTokenExtensions.cs b/Src/FluentAssertions.Json/Common/JTokenExtensions.cs new file mode 100644 index 00000000..6dd845c5 --- /dev/null +++ b/Src/FluentAssertions.Json/Common/JTokenExtensions.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json.Linq; + +namespace FluentAssertions.Json.Common +{ + internal static class JTokenExtensions + { + private static readonly JTokenComparer Comparer = new(); + + /// + /// Recursively sorts the properties of JObject instances by name and + /// the elements of JArray instances by their string representation, + /// producing a normalized JToken for consistent comparison + /// + public static JToken Normalize(this JToken token) + { + return token switch + { + JObject obj => new JObject(obj.Properties().OrderBy(p => p.Name).Select(p => new JProperty(p.Name, Normalize(p.Value)))), + JArray array => new JArray(array.Select(Normalize).OrderBy(x => x, Comparer)), + _ => token + }; + } + + private sealed class JTokenComparer : IComparer + { + public int Compare(JToken x, JToken y) + { + if (ReferenceEquals(x, y)) + return 0; + + if (x is null) + return -1; + + if (y is null) + return 1; + + var typeComparison = x.Type.CompareTo(y.Type); + if (typeComparison != 0) + return typeComparison; + + return x switch + { + JArray a => Compare(a, (JArray)y), + JObject o => Compare(o, (JObject)y), + JProperty p => Compare(p, (JProperty)y), + JValue v => Compare(v, (JValue)y), + JConstructor c => Compare(c, (JConstructor)y), + _ => string.Compare(x.ToString(), y.ToString(), StringComparison.Ordinal) + }; + } + + private static int Compare(JValue x, JValue y) => Comparer.Default.Compare(x.Value, y.Value); + + private static int Compare(JConstructor x, JConstructor y) + { + var nameComparison = string.Compare(x.Name, y.Name, StringComparison.Ordinal); + return nameComparison != 0 ? nameComparison : Compare(x, (JContainer)y); + } + + private static int Compare(JContainer x, JContainer y) + { + var countComparison = x.Count.CompareTo(y.Count); + if (countComparison != 0) + return countComparison; + + return x + .Select((t, i) => Comparer.Compare(t, y[i])) + .FirstOrDefault(itemComparison => itemComparison != 0); + } + + private static int Compare(JObject x, JObject y) + { + var countComparison = x.Count.CompareTo(y.Count); + if (countComparison != 0) + return countComparison; + + return x.Properties() + .OrderBy(p => p.Name) + .Zip(y.Properties().OrderBy(p => p.Name), (px, py) => Compare(px, py)) + .FirstOrDefault(itemComparison => itemComparison != 0); + } + + private static int Compare(JProperty x, JProperty y) + { + var nameComparison = string.Compare(x.Name, y.Name, StringComparison.Ordinal); + return nameComparison != 0 ? nameComparison : Comparer.Compare(x.Value, y.Value); + } + } + } +} diff --git a/Src/FluentAssertions.Json/IJsonAssertionOptions.cs b/Src/FluentAssertions.Json/IJsonAssertionOptions.cs index ef0c2d38..6b64d181 100644 --- a/Src/FluentAssertions.Json/IJsonAssertionOptions.cs +++ b/Src/FluentAssertions.Json/IJsonAssertionOptions.cs @@ -16,5 +16,10 @@ public interface IJsonAssertionOptions /// The assertion to execute when the predicate is met. /// IJsonAssertionRestriction Using(Action> action); + + /// + /// Configures the JSON assertion to ignore the order of elements in arrays or collections during comparison, allowing for equivalency checks regardless of element sequence. + /// + IJsonAssertionOptions WithoutStrictOrdering(); } } diff --git a/Src/FluentAssertions.Json/JTokenDifferentiator.cs b/Src/FluentAssertions.Json/JTokenDifferentiator.cs index f5c7cf77..38293bb6 100644 --- a/Src/FluentAssertions.Json/JTokenDifferentiator.cs +++ b/Src/FluentAssertions.Json/JTokenDifferentiator.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using FluentAssertions.Execution; +using FluentAssertions.Json.Common; using Newtonsoft.Json.Linq; namespace FluentAssertions.Json @@ -11,12 +12,14 @@ internal class JTokenDifferentiator private readonly bool ignoreExtraProperties; private readonly Func, IJsonAssertionOptions> config; + private readonly JsonAssertionOptions options; public JTokenDifferentiator(bool ignoreExtraProperties, Func, IJsonAssertionOptions> config) { this.ignoreExtraProperties = ignoreExtraProperties; this.config = config; + this.options = (JsonAssertionOptions)config(new JsonAssertionOptions()); } public Difference FindFirstDifference(JToken actual, JToken expected) @@ -38,6 +41,12 @@ public Difference FindFirstDifference(JToken actual, JToken expected) return new Difference(DifferenceKind.ExpectedIsNull, path); } + if (!options.IsStrictlyOrdered) + { + actual = actual.Normalize(); + expected = expected.Normalize(); + } + return FindFirstDifference(actual, expected, path); } diff --git a/Src/FluentAssertions.Json/JsonAssertionOptions.cs b/Src/FluentAssertions.Json/JsonAssertionOptions.cs index a331127a..f98a9ba6 100644 --- a/Src/FluentAssertions.Json/JsonAssertionOptions.cs +++ b/Src/FluentAssertions.Json/JsonAssertionOptions.cs @@ -4,17 +4,27 @@ namespace FluentAssertions.Json { /// - /// Represents the run-time type-specific behavior of a JSON structural equivalency assertion. It is the equivalent of + /// Represents the run-time type-specific behavior of a JSON structural equivalency assertion. It is the equivalent of /// public sealed class JsonAssertionOptions : EquivalencyOptions, IJsonAssertionOptions { + internal JsonAssertionOptions() { } + public JsonAssertionOptions(EquivalencyOptions equivalencyAssertionOptions) : base(equivalencyAssertionOptions) { - } + + internal bool IsStrictlyOrdered { get; private set; } = true; + public new IJsonAssertionRestriction Using(Action> action) { return new JsonAssertionRestriction(base.Using(action)); } + + public new IJsonAssertionOptions WithoutStrictOrdering() + { + IsStrictlyOrdered = false; + return this; + } } } diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/net47.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/net47.verified.txt index 74b35143..7b659d2f 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/net47.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/net47.verified.txt @@ -4,6 +4,7 @@ namespace FluentAssertions.Json public interface IJsonAssertionOptions { FluentAssertions.Json.IJsonAssertionRestriction Using(System.Action> action); + FluentAssertions.Json.IJsonAssertionOptions WithoutStrictOrdering(); } public interface IJsonAssertionRestriction { @@ -50,6 +51,7 @@ namespace FluentAssertions.Json { public JsonAssertionOptions(FluentAssertions.Equivalency.EquivalencyOptions equivalencyAssertionOptions) { } public FluentAssertions.Json.IJsonAssertionRestriction Using(System.Action> action) { } + public FluentAssertions.Json.IJsonAssertionOptions WithoutStrictOrdering() { } } public sealed class JsonAssertionRestriction : FluentAssertions.Json.IJsonAssertionRestriction { diff --git a/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/netstandard2.0.verified.txt b/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/netstandard2.0.verified.txt index 74b35143..7b659d2f 100644 --- a/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/netstandard2.0.verified.txt +++ b/Tests/Approval.Tests/ApprovedApi/FluentAssertions.Json/netstandard2.0.verified.txt @@ -4,6 +4,7 @@ namespace FluentAssertions.Json public interface IJsonAssertionOptions { FluentAssertions.Json.IJsonAssertionRestriction Using(System.Action> action); + FluentAssertions.Json.IJsonAssertionOptions WithoutStrictOrdering(); } public interface IJsonAssertionRestriction { @@ -50,6 +51,7 @@ namespace FluentAssertions.Json { public JsonAssertionOptions(FluentAssertions.Equivalency.EquivalencyOptions equivalencyAssertionOptions) { } public FluentAssertions.Json.IJsonAssertionRestriction Using(System.Action> action) { } + public FluentAssertions.Json.IJsonAssertionOptions WithoutStrictOrdering() { } } public sealed class JsonAssertionRestriction : FluentAssertions.Json.IJsonAssertionRestriction { diff --git a/Tests/FluentAssertions.Json.Specs/JTokenComparerSpecs.cs b/Tests/FluentAssertions.Json.Specs/JTokenComparerSpecs.cs new file mode 100644 index 00000000..ab3c086d --- /dev/null +++ b/Tests/FluentAssertions.Json.Specs/JTokenComparerSpecs.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace FluentAssertions.Json.Specs +{ + public class JTokenComparerSpecs + { + private static readonly IComparer Comparer = + Type.GetType("FluentAssertions.Json.Common.JTokenExtensions, FluentAssertions.Json")! + .GetField("Comparer", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)! + .GetValue(null) as IComparer; + + [Fact] + public void Should_return_zero_for_same_reference() + { + // Arrange + var token = JToken.Parse(@"{""a"":1}"); + + // Act & Assert + Comparer.Compare(token, token).Should().Be(0); + } + + [Fact] + public void Should_handle_nulls() + { + // Arrange + var token = JToken.Parse("1"); + + // Act & Assert + Comparer.Compare(null, token).Should().Be(-1); + Comparer.Compare(token, null).Should().Be(1); + Comparer.Compare(null, null).Should().Be(0); + } + + [Fact] + public void Should_compare_different_types() + { + // Arrange + var obj = JToken.Parse(@"{""a"":1}"); + var arr = JToken.Parse("[1]"); + + // Act & Assert + Comparer.Compare(obj, arr).Should().NotBe(0); + } + + [Fact] + public void Should_compare_jvalues() + { + // Arrange + var v1 = new JValue(1); + var v2 = new JValue(2); + + // Act & Assert + Comparer.Compare(v1, v2).Should().Be(-1); + Comparer.Compare(v2, v1).Should().Be(1); + Comparer.Compare(v1, new JValue(1)).Should().Be(0); + } + + [Fact] + public void Should_compare_jarrays_by_count_and_elements() + { + // Arrange + var arr1 = JArray.Parse("[1,2]"); + var arr2 = JArray.Parse("[1,2,3]"); + var arr3 = JArray.Parse("[1,3]"); + var arr4 = JArray.Parse("[1,2,3]"); + + // Act & Assert + Comparer.Compare(arr1, arr2).Should().Be(-1); + Comparer.Compare(arr1, arr3).Should().Be(-1); + Comparer.Compare(arr3, arr1).Should().Be(1); + Comparer.Compare(arr2, arr4).Should().Be(0); + } + + [Fact] + public void Should_compare_jobjects_by_count_and_properties() + { + // Arrange + var obj1 = JObject.Parse(@"{""a"":1}"); + var obj2 = JObject.Parse(@"{""a"":1,""b"":2}"); + var obj3 = JObject.Parse(@"{""a"":2}"); + var obj4 = JObject.Parse(@"{""a"":1,""b"":2}"); + var obj5 = JObject.Parse(@"{""b"":2}"); + + // Act & Assert + Comparer.Compare(obj1, obj2).Should().Be(-1); + Comparer.Compare(obj1, obj3).Should().Be(-1); + Comparer.Compare(obj3, obj1).Should().Be(1); + Comparer.Compare(obj2, obj4).Should().Be(0); + Comparer.Compare(obj1, obj5).Should().Be(-1); + Comparer.Compare(obj5, obj1).Should().Be(1); + } + + [Fact] + public void Should_compare_jproperties_by_name_and_value() + { + // Arrange + var prop1 = new JProperty("a", 1); + var prop2 = new JProperty("b", 1); + var prop3 = new JProperty("a", 2); + var prop4 = new JProperty("a", 1); + + // Act & Assert + Comparer.Compare(prop1, prop2).Should().Be(-1); + Comparer.Compare(prop1, prop3).Should().Be(-1); + Comparer.Compare(prop3, prop1).Should().Be(1); + Comparer.Compare(prop4, prop1).Should().Be(0); + Comparer.Compare(prop2, prop3).Should().Be(1); + Comparer.Compare(prop3, prop2).Should().Be(-1); + } + + [Fact] + public void Should_compare_jconstructors_by_name() + { + // Arrange + var ctor1 = new JConstructor("foo", new JValue(1)); + var ctor2 = new JConstructor("bar", new JValue(1)); + + // Act & Assert + Comparer.Compare(ctor1, ctor2).Should().BeGreaterThan(0); // "foo" > "bar" + } + + [Fact] + public void Should_compare_jconstructors_by_argument_count() + { + // Arrange + var ctor1 = new JConstructor("foo", new JValue(1)); + var ctor2 = new JConstructor("foo", new JValue(1), new JValue(2)); + + // Act & Assert + Comparer.Compare(ctor1, ctor2).Should().Be(-1); + } + + [Fact] + public void Should_compare_jconstructors_by_argument_values() + { + // Arrange + var ctor1 = new JConstructor("foo", new JValue(1), new JValue(2)); + var ctor2 = new JConstructor("foo", new JValue(1), new JValue(3)); + + // Act & Assert + Comparer.Compare(ctor1, ctor2).Should().Be(-1); + } + + [Fact] + public void Should_return_zero_for_equal_jconstructors() + { + // Arrange + var ctor1 = new JConstructor("foo", new JValue(1), new JValue(2)); + var ctor2 = new JConstructor("foo", new JValue(1), new JValue(2)); + + // Act & Assert + Comparer.Compare(ctor1, ctor2).Should().Be(0); + } + } +} diff --git a/Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs b/Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs new file mode 100644 index 00000000..2cd47d8e --- /dev/null +++ b/Tests/FluentAssertions.Json.Specs/WithoutStrictOrderingSpecs.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json.Linq; +using Xunit; +using Xunit.Sdk; + +namespace FluentAssertions.Json.Specs +{ + public class WithoutStrictOrderingSpecs + { + [Theory] + [MemberData(nameof(When_ignoring_ordering_BeEquivalentTo_should_succeed_sample_data))] + public void When_ignoring_ordering_BeEquivalentTo_should_succeed(string subject, string expectation) + { + // Arrange + var subjectJToken = JToken.Parse(subject); + var expectationJToken = JToken.Parse(expectation); + + // Act + subjectJToken.Should().BeEquivalentTo(expectationJToken, opt => opt.WithoutStrictOrdering()); + + // Assert + } + + public static TheoryData When_ignoring_ordering_BeEquivalentTo_should_succeed_sample_data() + { + return new TheoryData + { + { @"{""ids"":[1,2,3]}", @"{""ids"":[3,2,1]}" }, + { @"{""ids"":[1,2,3]}", @"{""ids"":[1,2,3]}" }, + { @"{""type"":2,""name"":""b""}", @"{""name"":""b"",""type"":2}" }, + { @"{""names"":[""a"",""b""]}", @"{""names"":[""b"",""a""]}" }, + { + @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", + @"{""vals"":[{""type"":2,""name"":""b""},{""name"":""a"",""type"":1}]}" + }, + { + @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", + @"{""vals"":[{""name"":""a"",""type"":1},{""type"":2,""name"":""b""}]}" + } + }; + } + + [Theory] + [MemberData(nameof(When_not_ignoring_ordering_BeEquivalentTo_should_throw_sample_data))] + public void When_not_ignoring_ordering_BeEquivalentTo_should_throw(string subject, string expectation) + { + // Arrange + var subjectJToken = JToken.Parse(subject); + var expectationJToken = JToken.Parse(expectation); + + // Act + var action = new Func>(() => subjectJToken.Should().BeEquivalentTo(expectationJToken)); + + // Assert + action.Should().Throw(); + } + + public static TheoryData When_not_ignoring_ordering_BeEquivalentTo_should_throw_sample_data() + { + return new TheoryData + { + { @"{""ids"":[1,2,3]}", @"{""ids"":[3,2,1]}" }, + { @"{""names"":[""a"",""b""]}", @"{""names"":[""b"",""a""]}" }, + { + @"{""vals"":[{""type"":1,""name"":""a""},{""name"":""b"",""type"":2}]}", + @"{""vals"":[{""type"":2,""name"":""b""},{""name"":""a"",""type"":1}]}" + } + }; + } + } +}