diff --git a/README.md b/README.md index d58312f5..6852403a 100644 --- a/README.md +++ b/README.md @@ -35,3 +35,19 @@ var expected = JToken.Parse(@"{ ""key2"" : ""value"" }"); actual.Should().BeEquivalentTo(expected); ``` +You can also use `IJsonAssertionOptions<>` with `Should().BeEquivalentTo()` assertions, which contains helper methods that you can use to specify the way you want to compare specific data types. + +Example: + +```c# +using FluentAssertions; +using FluentAssertions.Json; +using Newtonsoft.Json.Linq; + +... +var actual = JToken.Parse(@"{ ""value"" : 1.5 }"); +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 diff --git a/Src/FluentAssertions.Json/IJsonAssertionOptions.cs b/Src/FluentAssertions.Json/IJsonAssertionOptions.cs new file mode 100644 index 00000000..4fc2bcc7 --- /dev/null +++ b/Src/FluentAssertions.Json/IJsonAssertionOptions.cs @@ -0,0 +1,20 @@ +using System; +using FluentAssertions.Equivalency; + +namespace FluentAssertions.Json +{ + /// + /// Provides the run-time details of the class. + /// + public interface IJsonAssertionOptions + { + /// + /// Overrides the comparison of subject and expectation to use provided + /// when the predicate is met. + /// + /// + /// The assertion to execute when the predicate is met. + /// + IJsonAssertionRestriction Using(Action> action); + } +} diff --git a/Src/FluentAssertions.Json/IJsonAssertionRestriction.cs b/Src/FluentAssertions.Json/IJsonAssertionRestriction.cs new file mode 100644 index 00000000..da3bc9d2 --- /dev/null +++ b/Src/FluentAssertions.Json/IJsonAssertionRestriction.cs @@ -0,0 +1,14 @@ +namespace FluentAssertions.Json +{ + /// + /// Defines additional overrides when used with + /// + public interface IJsonAssertionRestriction + { + /// + /// Allows overriding the way structural equality is applied to (nested) objects of type + /// + /// + public IJsonAssertionOptions WhenTypeIs() where TMemberType : TMember; + } +} \ No newline at end of file diff --git a/Src/FluentAssertions.Json/JTokenAssertions.cs b/Src/FluentAssertions.Json/JTokenAssertions.cs index d3db7531..3c2fe73f 100644 --- a/Src/FluentAssertions.Json/JTokenAssertions.cs +++ b/Src/FluentAssertions.Json/JTokenAssertions.cs @@ -84,13 +84,36 @@ public AndConstraint BeEquivalentTo(string expected, string be public AndConstraint BeEquivalentTo(JToken expected, string because = "", params object[] becauseArgs) { - return BeEquivalentTo(expected, false, because, becauseArgs); + return BeEquivalentTo(expected, false, options => options, because, becauseArgs); } - private AndConstraint BeEquivalentTo(JToken expected, bool ignoreExtraProperties, string because = "", + /// + /// Asserts that the current is equivalent to the element, + /// using an equivalent of . + /// + /// The expected element + /// The options to consider while asserting values + /// + /// A formatted phrase as is supported by explaining why the assertion + /// is needed. If the phrase does not start with the word because, it is prepended automatically. + /// + /// + /// Zero or more objects to format using the placeholders in . + /// + public AndConstraint BeEquivalentTo(JToken expected, + Func, IJsonAssertionOptions> config, + string because = "", + params object[] becauseArgs) + { + return BeEquivalentTo(expected, false, config, because, becauseArgs); + } + + private AndConstraint BeEquivalentTo(JToken expected, bool ignoreExtraProperties, + Func, IJsonAssertionOptions> config, + string because = "", params object[] becauseArgs) { - Difference difference = JTokenDifferentiator.FindFirstDifference(Subject, expected, ignoreExtraProperties); + Difference difference = JTokenDifferentiator.FindFirstDifference(Subject, expected, ignoreExtraProperties, config); var expectation = ignoreExtraProperties ? "was expected to contain" : "was expected to be equivalent to"; @@ -463,7 +486,7 @@ public AndConstraint ContainSubtree(string subtree, string bec /// public AndConstraint ContainSubtree(JToken subtree, string because = "", params object[] becauseArgs) { - return BeEquivalentTo(subtree, true, because, becauseArgs); + return BeEquivalentTo(subtree, true, options => options, because, becauseArgs); } public string Format(JToken value, bool useLineBreaks = false) diff --git a/Src/FluentAssertions.Json/JTokenDifferentiator.cs b/Src/FluentAssertions.Json/JTokenDifferentiator.cs index 852ea9e2..cccdc7c6 100644 --- a/Src/FluentAssertions.Json/JTokenDifferentiator.cs +++ b/Src/FluentAssertions.Json/JTokenDifferentiator.cs @@ -1,13 +1,16 @@ using System; using System.Collections.Generic; using System.Linq; +using FluentAssertions.Execution; using Newtonsoft.Json.Linq; namespace FluentAssertions.Json { + // REFACTOR: Change to non-static and make parameters fields internal static class JTokenDifferentiator { - public static Difference FindFirstDifference(JToken actual, JToken expected, bool ignoreExtraProperties) + + public static Difference FindFirstDifference(JToken actual, JToken expected, bool ignoreExtraProperties, Func, IJsonAssertionOptions> config) { var path = new JPath(); @@ -26,28 +29,29 @@ public static Difference FindFirstDifference(JToken actual, JToken expected, boo return new Difference(DifferenceKind.ExpectedIsNull, path); } - return FindFirstDifference(actual, expected, path, ignoreExtraProperties); + return FindFirstDifference(actual, expected, path, ignoreExtraProperties, config); } - private static Difference FindFirstDifference(JToken actual, JToken expected, JPath path, bool ignoreExtraProperties) + private static Difference FindFirstDifference(JToken actual, JToken expected, JPath path, bool ignoreExtraProperties, Func, IJsonAssertionOptions> config) { switch (actual) { case JArray actualArray: - return FindJArrayDifference(actualArray, expected, path, ignoreExtraProperties); + return FindJArrayDifference(actualArray, expected, path, ignoreExtraProperties, config); case JObject actualObject: - return FindJObjectDifference(actualObject, expected, path, ignoreExtraProperties); + return FindJObjectDifference(actualObject, expected, path, ignoreExtraProperties, config); case JProperty actualProperty: - return FindJPropertyDifference(actualProperty, expected, path, ignoreExtraProperties); + return FindJPropertyDifference(actualProperty, expected, path, ignoreExtraProperties, config); case JValue actualValue: - return FindValueDifference(actualValue, expected, path); + return FindValueDifference(actualValue, expected, path, config); default: throw new NotSupportedException(); } } private static Difference FindJArrayDifference(JArray actualArray, JToken expected, JPath path, - bool ignoreExtraProperties) + bool ignoreExtraProperties, + Func, IJsonAssertionOptions> config) { if (!(expected is JArray expectedArray)) { @@ -56,15 +60,15 @@ private static Difference FindJArrayDifference(JArray actualArray, JToken expect if (ignoreExtraProperties) { - return CompareExpectedItems(actualArray, expectedArray, path); + return CompareExpectedItems(actualArray, expectedArray, path, config); } else { - return CompareItems(actualArray, expectedArray, path); + return CompareItems(actualArray, expectedArray, path, config); } } - private static Difference CompareExpectedItems(JArray actual, JArray expected, JPath path) + private static Difference CompareExpectedItems(JArray actual, JArray expected, JPath path, Func, IJsonAssertionOptions> config) { JToken[] actualChildren = actual.Children().ToArray(); JToken[] expectedChildren = expected.Children().ToArray(); @@ -76,7 +80,7 @@ private static Difference CompareExpectedItems(JArray actual, JArray expected, J bool match = false; for (int actualIndex = matchingIndex; actualIndex < actualChildren.Length; actualIndex++) { - var difference = FindFirstDifference(actualChildren[actualIndex], expectedChild, true); + var difference = FindFirstDifference(actualChildren[actualIndex], expectedChild, true, config); if (difference == null) { @@ -90,7 +94,7 @@ private static Difference CompareExpectedItems(JArray actual, JArray expected, J { if (matchingIndex >= actualChildren.Length) { - if (actualChildren.Any(actualChild => FindFirstDifference(actualChild, expectedChild, true) == null)) + if (actualChildren.Any(actualChild => FindFirstDifference(actualChild, expectedChild, true, config) == null)) { return new Difference(DifferenceKind.WrongOrder, path.AddIndex(expectedIndex)); } @@ -99,14 +103,15 @@ private static Difference CompareExpectedItems(JArray actual, JArray expected, J } return FindFirstDifference(actualChildren[matchingIndex], expectedChild, - path.AddIndex(expectedIndex), true); + path.AddIndex(expectedIndex), true, config); } } return null; } - private static Difference CompareItems(JArray actual, JArray expected, JPath path) + private static Difference CompareItems(JArray actual, JArray expected, JPath path, + Func, IJsonAssertionOptions> config) { JToken[] actualChildren = actual.Children().ToArray(); JToken[] expectedChildren = expected.Children().ToArray(); @@ -119,7 +124,7 @@ private static Difference CompareItems(JArray actual, JArray expected, JPath pat for (int i = 0; i < actualChildren.Length; i++) { Difference firstDifference = FindFirstDifference(actualChildren[i], expectedChildren[i], - path.AddIndex(i), false); + path.AddIndex(i), false, config); if (firstDifference != null) { @@ -130,18 +135,20 @@ private static Difference CompareItems(JArray actual, JArray expected, JPath pat return null; } - private static Difference FindJObjectDifference(JObject actual, JToken expected, JPath path, bool ignoreExtraProperties) + private static Difference FindJObjectDifference(JObject actual, JToken expected, JPath path, bool ignoreExtraProperties, + Func, IJsonAssertionOptions> config) { if (!(expected is JObject expectedObject)) { return new Difference(DifferenceKind.OtherType, path, Describe(actual.Type), Describe(expected.Type)); } - return CompareProperties(actual?.Properties(), expectedObject.Properties(), path, ignoreExtraProperties); + return CompareProperties(actual?.Properties(), expectedObject.Properties(), path, ignoreExtraProperties, config); } private static Difference CompareProperties(IEnumerable actual, IEnumerable expected, JPath path, - bool ignoreExtraProperties) + bool ignoreExtraProperties, + Func, IJsonAssertionOptions> config) { var actualDictionary = actual?.ToDictionary(p => p.Name, p => p.Value) ?? new Dictionary(); var expectedDictionary = expected?.ToDictionary(p => p.Name, p => p.Value) ?? new Dictionary(); @@ -167,7 +174,7 @@ private static Difference CompareProperties(IEnumerable actual, IEnum JToken actualValue = actualDictionary[expectedPair.Key]; Difference firstDifference = FindFirstDifference(actualValue, expectedPair.Value, - path.AddProperty(expectedPair.Key), ignoreExtraProperties); + path.AddProperty(expectedPair.Key), ignoreExtraProperties, config); if (firstDifference != null) { @@ -179,7 +186,8 @@ private static Difference CompareProperties(IEnumerable actual, IEnum } private static Difference FindJPropertyDifference(JProperty actualProperty, JToken expected, JPath path, - bool ignoreExtraProperties) + bool ignoreExtraProperties, + Func, IJsonAssertionOptions> config) { if (!(expected is JProperty expectedProperty)) { @@ -191,31 +199,36 @@ private static Difference FindJPropertyDifference(JProperty actualProperty, JTok return new Difference(DifferenceKind.OtherName, path); } - return FindFirstDifference(actualProperty.Value, expectedProperty.Value, path, ignoreExtraProperties); + return FindFirstDifference(actualProperty.Value, expectedProperty.Value, path, ignoreExtraProperties, config); } - private static Difference FindValueDifference(JValue actualValue, JToken expected, JPath path) + private static Difference FindValueDifference(JValue actualValue, JToken expected, JPath path, Func, IJsonAssertionOptions> config) { if (!(expected is JValue expectedValue)) { return new Difference(DifferenceKind.OtherType, path, Describe(actualValue.Type), Describe(expected.Type)); } - return CompareValues(actualValue, expectedValue, path); + return CompareValues(actualValue, expectedValue, path, config); } - private static Difference CompareValues(JValue actual, JValue expected, JPath path) + private static Difference CompareValues(JValue actual, JValue expected, JPath path, Func, IJsonAssertionOptions> config) { if (actual.Type != expected.Type) { return new Difference(DifferenceKind.OtherType, path, Describe(actual.Type), Describe(expected.Type)); } - - if (!actual.Equals(expected)) + + bool hasMismatches; + using (var scope = new AssertionScope()) + { + actual.Value.Should().BeEquivalentTo(expected.Value, options => (JsonAssertionOptions)config.Invoke(new JsonAssertionOptions(options))); + hasMismatches = scope.Discard().Length > 0; + } + if(hasMismatches) { return new Difference(DifferenceKind.OtherValue, path); } - return null; } diff --git a/Src/FluentAssertions.Json/JsonAssertionOptions.cs b/Src/FluentAssertions.Json/JsonAssertionOptions.cs new file mode 100644 index 00000000..ccb3bd0e --- /dev/null +++ b/Src/FluentAssertions.Json/JsonAssertionOptions.cs @@ -0,0 +1,20 @@ +using System; +using FluentAssertions.Equivalency; + +namespace FluentAssertions.Json +{ + /// + /// Represents the run-time type-specific behavior of a JSON structural equivalency assertion. It is the equivalent of + /// + public sealed class JsonAssertionOptions : EquivalencyAssertionOptions , IJsonAssertionOptions + { + public JsonAssertionOptions(EquivalencyAssertionOptions equivalencyAssertionOptions) : base(equivalencyAssertionOptions) + { + + } + public new IJsonAssertionRestriction Using(Action> action) + { + return new JsonAssertionRestriction(base.Using(action)); + } + } +} \ No newline at end of file diff --git a/Src/FluentAssertions.Json/JsonAssertionRestriction.cs b/Src/FluentAssertions.Json/JsonAssertionRestriction.cs new file mode 100644 index 00000000..6cdcc928 --- /dev/null +++ b/Src/FluentAssertions.Json/JsonAssertionRestriction.cs @@ -0,0 +1,17 @@ +namespace FluentAssertions.Json +{ + public sealed class JsonAssertionRestriction : IJsonAssertionRestriction + { + private readonly JsonAssertionOptions.Restriction restriction; + + internal JsonAssertionRestriction(JsonAssertionOptions.Restriction restriction) + { + this.restriction = restriction; + } + + public IJsonAssertionOptions WhenTypeIs() where TMemberType : TProperty + { + return (JsonAssertionOptions)restriction.WhenTypeIs(); + } + } +} \ No newline at end of file diff --git a/Tests/FluentAssertions.Json.Specs/FluentAssertions.Json.Specs.csproj b/Tests/FluentAssertions.Json.Specs/FluentAssertions.Json.Specs.csproj index 10fee5b6..dc441b8e 100644 --- a/Tests/FluentAssertions.Json.Specs/FluentAssertions.Json.Specs.csproj +++ b/Tests/FluentAssertions.Json.Specs/FluentAssertions.Json.Specs.csproj @@ -1,9 +1,9 @@ - + false - + 8.0 net47;netcoreapp3.0 diff --git a/Tests/FluentAssertions.Json.Specs/JTokenAssertionsSpecs.cs b/Tests/FluentAssertions.Json.Specs/JTokenAssertionsSpecs.cs index cf0df6dc..f03408c5 100644 --- a/Tests/FluentAssertions.Json.Specs/JTokenAssertionsSpecs.cs +++ b/Tests/FluentAssertions.Json.Specs/JTokenAssertionsSpecs.cs @@ -445,6 +445,41 @@ public void When_a_token_is_equal_to_its_string_representation_the_non_equivalen .WithMessage("Expected JSON document not to be equivalent*"); } + [Fact] + public void When_a_float_is_within_approximation_check_should_succeed() + { + //----------------------------------------------------------------------------------------------------------- + // Arrange + //----------------------------------------------------------------------------------------------------------- + var actual = JToken.Parse("{ \"id\": 1.1232 }"); + var expected = JToken.Parse("{ \"id\": 1.1235 }"); + //----------------------------------------------------------------------------------------------------------- + // Act & Assert + //----------------------------------------------------------------------------------------------------------- + actual.Should().BeEquivalentTo(expected, + options => options + .Using(d => d.Subject.Should().BeApproximately(d.Expectation, 1e-3)) + .WhenTypeIs()); + } + [Fact] + public void When_a_float_is_not_within_approximation_check_should_throw() + { + //----------------------------------------------------------------------------------------------------------- + // Arrange + //----------------------------------------------------------------------------------------------------------- + var actual = JToken.Parse("{ \"id\": 1.1232 }"); + var expected = JToken.Parse("{ \"id\": 1.1235 }"); + //----------------------------------------------------------------------------------------------------------- + // Act & Assert + //----------------------------------------------------------------------------------------------------------- + + actual.Should(). + Invoking(x => x.BeEquivalentTo(expected,options => options + .Using(d => d.Subject.Should().BeApproximately(d.Expectation, 1e-5)) + .WhenTypeIs())) + .Should().Throw() + .WithMessage("JSON document has a different value at $.id.*"); + } [Fact] public void When_the_value_of_a_property_contains_curly_braces_the_equivalency_check_should_not_choke_on_them() { diff --git a/Tests/FluentAssertions.Json.Specs/JsonAssertionOptionsSpecs.cs b/Tests/FluentAssertions.Json.Specs/JsonAssertionOptionsSpecs.cs new file mode 100644 index 00000000..87f2cfdd --- /dev/null +++ b/Tests/FluentAssertions.Json.Specs/JsonAssertionOptionsSpecs.cs @@ -0,0 +1,40 @@ +using System; +using FluentAssertions.Equivalency; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace FluentAssertions.Json.Specs +{ + // Due to tests that call AssertionOptions + [CollectionDefinition("AssertionOptionsSpecs", DisableParallelization = true)] + public class AssertionOptionsSpecsDefinition { } + [Collection("AssertionOptionsSpecs")] + public class JsonAssertionOptionsSpecs + { + [Fact] + public void Local_equivalency_options_are_applied_on_top_of_global_equivalency_options() + { + using var assertionOptions = new TempDefaultAssertionOptions(e => e + .Using(ctx => ctx.Subject.Should().BeApproximately(ctx.Expectation, 0.1)) + .WhenTypeIs()); + // Arrange + var actual = JToken.Parse("{ \"id\": 1.1232 }"); + var expected = JToken.Parse("{ \"id\": 1.1235 }"); + + // Act & Assert + actual.Should().BeEquivalentTo(expected, options => options); + } + private sealed class TempDefaultAssertionOptions : IDisposable + { + public TempDefaultAssertionOptions(Func config) + { + AssertionOptions.AssertEquivalencyUsing(config); + } + public void Dispose() + { + AssertionOptions.AssertEquivalencyUsing(_ => new EquivalencyAssertionOptions()); + } + } + } + +} \ No newline at end of file