Record type gotcha with FluentAssertions 5

TL;DR: Use version 6+ of FluentAssertions when asserting equality of record types with collections in them.

I already mentioned the possible unexpected behavior of ToString in base class in another post.

This time I write about unexpected behavior of records when used with common libraries.

An important library I use for writing assertions in tests is FluentAssertions. One of its most important features is its built-in structural equality check in the BeEquivalentTo assertion method. The method saves you from implementing Equals on types just because you need structural equality checking in your unit tests. You can even compare any instance to any other instance independent of type. If they are structurally equal, BeEquivalentTo will let the assertion pass.

Now, guess what happens if we try BeEquivalentTo on record types? Will it just work because of their compiler-synthesized Equals method? Will it just work because of FluentAssertions’ structural equality mechanism?

With some records, everything seems to be fine, like in this example:

record Point(int X, int Y);

public void SomeTest()
{
    Point point1 = new(4, 2);
    Point point2 = new(4, 2);

    // passes
    point1.Should().BeEquivalentTo(point2);
}

But with other records, behavior maybe unexpected:

record Point(int X, int Y);
record PointCollection(IEnumerable<Point> Points);

public void SomeTest()
{
    Point point1 = new(1, 2);
    Point point2 = new(3, 4);

    PointCollection pointCollection1 = new(new[] { point1, point2 });
    PointCollection pointCollection2 = new(new[] { point1, point2 });

    // fails
    pointCollection1.Should().BeEquivalentTo(pointCollection2);
}

What happened under the hood to make this test fail?

Because record types are reference types, FluentAssertions checks whether they override the Equals methods, which they do because of the compiler-synthesized methods. Because they override Equals, FluentAssertions will not use its own structural equality check, but the record type’s Equals method.

This does not make a difference in the first example and not even when we nest records. But as soon as we use standard collection types in our records, it will break our tests. None of the collection types in the .NET library — not even ImmutableArray<T> — implements its Equals method by element-wise comparison.

You now have a couple of options to save your tests.

The simplest one is to switch to (at the time of writing) the latest preview version of FluentAssertions 6, which has different default behavior for record types (see discussion and PR).

If you cannot do that for some reason, you can force FluentAssertions to use member-wise comparison with ShouldBeEquivalentTo(myRecord, opts => opts.CompareByMember<MyRecord>()).

This becomes verbose and annoying really fast. Even creating a new convenience extension method like ShouldBeMemberwiseEquivalentTo<T> is not of much help. Because as soon as you have nested records like in the second example above, you have to tell FluentAssertions to use member-wise equality check for every! single! type! in your data structure, like this

pointCollection1.Should().BeEquivalentTo(
    pointCollection2,
    opts => opts.CompareByMember<PointCollection>().CompareByMember<Point>())

If you can help it, use FluentAssertions 6 and it will Just Work.