fluentassertions.json icon indicating copy to clipboard operation
fluentassertions.json copied to clipboard

Feature request: Allow using the working comparison even when JTokens are nested

Open chrischu opened this issue 3 years ago • 8 comments

Currently when running the following code:

var d = new Dictionary<string, JToken> { ["a"] = 5 };
var e = new Dictionary<string, JToken> { ["a"] = 7 };
d.Should().BeEquivalentTo(e);

The difference between the values is not detected (i.e. no assertion error is raised). It would be cool if this NuGet package would offer some kind of extension method on EquivalencyAssertionOptions (or similar) to allow me to make the comparison work correctly.

chrischu avatar Feb 10 '22 07:02 chrischu

It seems what you're looking for is a combination of what FluentAssertions and FluentAssertions.Json does. Something that plugs in support for JToken (which depends on either Newtonsoft or Microsoft's library).

dennisdoomen avatar Feb 10 '22 08:02 dennisdoomen

Yes that is what I'm looking for, I assumed this project would be the right place for it given its name and the fact that it already combines FluentAssertion with Newtonsoft's JToken, albeit only for top-level comparisons :).

chrischu avatar Feb 10 '22 08:02 chrischu

You can extend the equivalency comparisons with the BeEquivalentTo from FluentAssertions.Json to add the special handling of JToken. Here are two examples depending on whether you want to to it for a single test or for all tests.

[TestMethod]
public void LocalOptions()
{
    var d = new Dictionary<string, JToken> { ["a"] = 5 };
    var e = new Dictionary<string, JToken> { ["a"] = 7 };
    d.Should().BeEquivalentTo(e, opt => opt
        .Using<JToken>(ctx => ctx.Subject.Should().BeEquivalentTo(ctx.Expectation))
        .WhenTypeIs<JToken>()
    );
}

[TestMethod]
public void GlobalOptions()
{
    AssertionOptions.AssertEquivalencyUsing(e => e
        .Using<JToken>(ctx => ctx.Subject.Should().BeEquivalentTo(ctx.Expectation))
        .WhenTypeIs<JToken>()
    );

    var d = new Dictionary<string, JToken> { ["a"] = 5 };
    var e = new Dictionary<string, JToken> { ["a"] = 7 };
    d.Should().BeEquivalentTo(e);
}

jnyrup avatar Feb 11 '22 15:02 jnyrup

Indeed. But I was playing with the idea of having some kind of automatic configuration where adding a reference to the JSON package would automatically add the relevant IEquivalencyStep in the background.

dennisdoomen avatar Feb 11 '22 15:02 dennisdoomen

@jnyrup Thanks for the idea, I did not consider that. This will work as a workaround until there might be a better solution :).

chrischu avatar Feb 12 '22 09:02 chrischu

I tried the solution proposed by @jnyrup and it works, unfortunately it kind of breaks the nice display of assertion errors we usually get with Should().BeEquivalentTo(), e.g. if we compare an object with multiple JToken instances and two of them are not as expected we get the following error which does not offer any information on which property held the JToken instance that was not as expected: image

Is there any way to solve this problem (by building a more complex IEquivalencyStep or something along those lines)?

chrischu avatar Feb 15 '22 14:02 chrischu

I think the limitation is in FluentAssertions.Json. It doesn't use the {context} construct in its implementation of BeEquivalentTo. See https://github.com/fluentassertions/fluentassertions.json/blob/master/Src/FluentAssertions.Json/JTokenAssertions.cs#L90

dennisdoomen avatar Feb 15 '22 16:02 dennisdoomen

If you're okay with using a hacky workaround, which might break at any time.

class JTokenEquivalencyStep : EquivalencyStep<JToken>
{
    protected override EquivalencyResult OnHandle(Comparands comparands, IEquivalencyValidationContext context, IEquivalencyValidator nestedValidator)
    {
        string message = null;
        using (var assertionScope = new AssertionScope())
        {
            var subject = comparands.Subject as JToken;
            var expectation = comparands.Expectation as JToken;

            subject.Should().BeEquivalentTo(expectation, context.Reason.FormattedMessage, context.Reason.Arguments);
            if (assertionScope.HasFailures())
            {
                message = assertionScope.Discard()[0].Replace("JSON document", "{context:JSON document}");
            }
        }

        if (message is not null)
        {
            AssertionScope.Current.FailWith(message);
        }

        return EquivalencyResult.AssertionCompleted;
    }
}
var subject = new { First = (JToken)1, Second = (JToken)2 };
var expectation = new { First = (JToken)3, Second = (JToken)4 };
subject.Should().BeEquivalentTo(expectation,
    options => options.Using(new JTokenEquivalencyStep()),
    "my {0} message", "failure");
Property root.First has a different value at $.
Actual document
1
was expected to be equivalent to
3
 because my failure message.
Property root.Second has a different value at $.
Actual document
2
was expected to be equivalent to
4
 because my failure message.

With configuration:
- Use declared types and members
- Compare enums by value
- Compare tuples by their properties
- Compare anonymous types by their properties
- Compare records by their members
- Match member by name (or throw)
- TestProject11.UnitTest1+JTokenEquivalencyStep
- Be strict about the order of items in byte arrays
- Without automatic conversion.

jnyrup avatar Feb 16 '22 11:02 jnyrup