Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,22 @@ var expected = JToken.Parse(@"{ ""value"" : 1.4 }");
actual.Should().BeEquivalentTo(expected, options => options
.Using<double>(d => d.Subject.Should().BeApproximately(d.Expectation, 0.1))
.WhenTypeIs<double>());
```
```

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());
```
93 changes: 93 additions & 0 deletions Src/FluentAssertions.Json/Common/JTokenExtensions.cs
Original file line number Diff line number Diff line change
@@ -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();

/// <summary>
/// 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
/// </summary>
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<JToken>
{
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<object>.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);
}
}
}
}
5 changes: 5 additions & 0 deletions Src/FluentAssertions.Json/IJsonAssertionOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,10 @@ public interface IJsonAssertionOptions<T>
/// The assertion to execute when the predicate is met.
/// </param>
IJsonAssertionRestriction<T, TProperty> Using<TProperty>(Action<IAssertionContext<TProperty>> action);

/// <summary>
/// Configures the JSON assertion to ignore the order of elements in arrays or collections during comparison, allowing for equivalency checks regardless of element sequence.
/// </summary>
IJsonAssertionOptions<T> WithoutStrictOrdering();
}
}
9 changes: 9 additions & 0 deletions Src/FluentAssertions.Json/JTokenDifferentiator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -11,12 +12,14 @@ internal class JTokenDifferentiator
private readonly bool ignoreExtraProperties;

private readonly Func<IJsonAssertionOptions<object>, IJsonAssertionOptions<object>> config;
private readonly JsonAssertionOptions<object> options;

public JTokenDifferentiator(bool ignoreExtraProperties,
Func<IJsonAssertionOptions<object>, IJsonAssertionOptions<object>> config)
{
this.ignoreExtraProperties = ignoreExtraProperties;
this.config = config;
this.options = (JsonAssertionOptions<object>)config(new JsonAssertionOptions<object>());
}

public Difference FindFirstDifference(JToken actual, JToken expected)
Expand All @@ -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);
}

Expand Down
14 changes: 12 additions & 2 deletions Src/FluentAssertions.Json/JsonAssertionOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,27 @@
namespace FluentAssertions.Json
{
/// <summary>
/// Represents the run-time type-specific behavior of a JSON structural equivalency assertion. It is the equivalent of <see cref="FluentAssertions.Equivalency.EquivalencyAssertionOptions{T}"/>
/// Represents the run-time type-specific behavior of a JSON structural equivalency assertion. It is the equivalent of <see cref="FluentAssertions.Equivalency.EquivalencyOptions{T}"/>
/// </summary>
public sealed class JsonAssertionOptions<T> : EquivalencyOptions<T>, IJsonAssertionOptions<T>
{
internal JsonAssertionOptions() { }

public JsonAssertionOptions(EquivalencyOptions<T> equivalencyAssertionOptions) : base(equivalencyAssertionOptions)
{

}

internal bool IsStrictlyOrdered { get; private set; } = true;

public new IJsonAssertionRestriction<T, TProperty> Using<TProperty>(Action<IAssertionContext<TProperty>> action)
{
return new JsonAssertionRestriction<T, TProperty>(base.Using(action));
}

public new IJsonAssertionOptions<T> WithoutStrictOrdering()
{
IsStrictlyOrdered = false;
return this;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace FluentAssertions.Json
public interface IJsonAssertionOptions<T>
{
FluentAssertions.Json.IJsonAssertionRestriction<T, TProperty> Using<TProperty>(System.Action<FluentAssertions.Equivalency.IAssertionContext<TProperty>> action);
FluentAssertions.Json.IJsonAssertionOptions<T> WithoutStrictOrdering();
}
public interface IJsonAssertionRestriction<T, TMember>
{
Expand Down Expand Up @@ -50,6 +51,7 @@ namespace FluentAssertions.Json
{
public JsonAssertionOptions(FluentAssertions.Equivalency.EquivalencyOptions<T> equivalencyAssertionOptions) { }
public FluentAssertions.Json.IJsonAssertionRestriction<T, TProperty> Using<TProperty>(System.Action<FluentAssertions.Equivalency.IAssertionContext<TProperty>> action) { }
public FluentAssertions.Json.IJsonAssertionOptions<T> WithoutStrictOrdering() { }
}
public sealed class JsonAssertionRestriction<T, TProperty> : FluentAssertions.Json.IJsonAssertionRestriction<T, TProperty>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ namespace FluentAssertions.Json
public interface IJsonAssertionOptions<T>
{
FluentAssertions.Json.IJsonAssertionRestriction<T, TProperty> Using<TProperty>(System.Action<FluentAssertions.Equivalency.IAssertionContext<TProperty>> action);
FluentAssertions.Json.IJsonAssertionOptions<T> WithoutStrictOrdering();
}
public interface IJsonAssertionRestriction<T, TMember>
{
Expand Down Expand Up @@ -50,6 +51,7 @@ namespace FluentAssertions.Json
{
public JsonAssertionOptions(FluentAssertions.Equivalency.EquivalencyOptions<T> equivalencyAssertionOptions) { }
public FluentAssertions.Json.IJsonAssertionRestriction<T, TProperty> Using<TProperty>(System.Action<FluentAssertions.Equivalency.IAssertionContext<TProperty>> action) { }
public FluentAssertions.Json.IJsonAssertionOptions<T> WithoutStrictOrdering() { }
}
public sealed class JsonAssertionRestriction<T, TProperty> : FluentAssertions.Json.IJsonAssertionRestriction<T, TProperty>
{
Expand Down
158 changes: 158 additions & 0 deletions Tests/FluentAssertions.Json.Specs/JTokenComparerSpecs.cs
Original file line number Diff line number Diff line change
@@ -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<JToken> Comparer =
Type.GetType("FluentAssertions.Json.Common.JTokenExtensions, FluentAssertions.Json")!
.GetField("Comparer", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic)!
.GetValue(null) as IComparer<JToken>;

[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);
}
}
}
Loading