FluentAssertions Should().BeEquivalentTo() fails in trivial case when types are C# 9 records, seemingly treating objects as strings
Asked Answered
G

2

12

I started to use FluentAssertions recently, which supposed to have this powerful object graph comparison feature.

I'm trying to do the simplest thing imaginable: compare the properties of an Address object with the properties of an AddressDto object. They both contain 4 simple string properties: Country, City, Street, and ZipCode (it's not a production system).

Could someone explain to me, like I'm two years old, what is going wrong?

partnerDto.Address.Should().BeEquivalentTo(partner.Address)

And it fails with this message:

Message:

Expected result.Address to be 4 Some street, 12345 Toronto, Canada, but found AddressDto { Country = Canada, ZipCode = 12345, City = Toronto, Street = 4 Some street }.

With configuration:

  • Use declared types and members
  • Compare enums by value
  • Match member by name (or throw)
  • Without automatic conversion.
  • Be strict about the order of items in byte arrays

It seems it tries to treat the Address object as a string (because it overrides ToString()?). I tried to use the options.ComparingByMembers<AddressDto>() option, but seemingly it makes no difference.

(AddressDto is a record btw, not a class, since I'm testing out new .Net 5 features with this project; but it probably makes no difference.)


Moral of the story:

Using record instead of class trips FluentAssertions, because records automatically override Equals() in the background, and FluentAssertions assumes it should use Equals() instead of property comparisons, because the overridden Equals() is probably there to provide the required comparison.

But, in this case the default override implementation of Equals() in a record actually only works if the two types are the same, so it fails, and thus FluentAssertions reports a failure on BeEquivalentTo().

And, in the failure message FluentAssertions confusingly reports the issue by converting the objects to string via ToString(). This is because records have 'value semantics', so it treats them as such. There is an open issue about this on GitHub.

I confirmed that the problem does not occur if I change record to class.

(I personally think FluentAssertions should be ignoring Equals() override when it's on a record and the two types are different, since this behavior is arguably not what people would expect. The current question, at the time of posting, pertains to FluentAssertions version 5.10.3.)

I edited my question title to better represent what the problem actually is, so it could be more useful for people.


References:

As people asked, here is the definition of the domain entity (had to remove some methods for brevity, since I'm doing DDD, but they were surely irrelevant to the question):

public class Partner : MyEntity
{
    [Required]
    [StringLength(PartnerInvariants.NameMaxLength)]
    public string Name { get; private set; }

    [Required]
    public Address Address { get; private set; }

    public virtual IReadOnlyCollection<Transaction> Transactions => _transactions.AsReadOnly();
    private List<Transaction> _transactions = new List<Transaction>();

    private Partner()
    { }

    public Partner(string name, Address address)
    {
        UpdateName(name);
        UpdateAddress(address);
    }

    ...

    public void UpdateName(string value)
    {
        ...
    }

    public void UpdateAddress(Address address)
    {
        ...
    }

    ...
}

public record Address
{
    [Required, MinLength(1), MaxLength(100)]
    public string Street { get; init; }

    [Required, MinLength(1), MaxLength(100)]
    public string City { get; init; }

    // As I mentioned, it's not a production system :)
    [Required, MinLength(1), MaxLength(100)]
    public string Country { get; init; }

    [Required, MinLength(1), MaxLength(100)]
    public string ZipCode { get; init; }

    private Address() { }

    public Address(string street, string city, string country, string zipcode)
        => (Street, City, Country, ZipCode) = (street, city, country, zipcode);

    public override string ToString()
        => $"{Street}, {ZipCode} {City}, {Country}";
}

And here are the Dto equivalents:

public record PartnerDetailsDto : IMapFrom<Partner>
{
    public int Id { get; init; }
    public string Name { get; init; }
    public DateTime CreatedAt { get; init; }
    public DateTime? LastModifiedAt { get; init; }

    public AddressDto Address { get; init; }

    public void Mapping(Profile profile)
    {
        profile.CreateMap<Partner, PartnerDetailsDto>();
        profile.CreateMap<Address, AddressDto>();
    }

    public record AddressDto
    {
        public string Country { get; init; }
        public string ZipCode { get; init; }
        public string City { get; init; }
        public string Street { get; init; }
    }
}
Giule answered 2/2, 2021 at 10:2 Comment(8)
Can you edit the question to include the definitions of Address and AddressDto?Synonymize
Sure thing, @canton7; just a momentGiule
What is the type of partnerDto variable? And how isAddress property defined?Mallard
@PavelAnikhouski the type of partnerDto is a simple PartnerDetailsDto (record, not class) with properties equivalent to the ones defined in the domain entity Partner. The PartnerDetailsDto.Address is of type AddressDto (its definition was added to the question), which is a public record defined inside PartnerDetailsDto.Giule
@Giule we'll need all these definitions to be able to reproduce your problemMallard
Okay, I'll add all of them, just a secGiule
Added them. Sorry, it took a while, since I had to remove some parts for brevity.Giule
FYI, there is an open issue about this github.com/fluentassertions/fluentassertions/issues/1451Womanize
P
11

Have you tried using the options.ComparingByMembers<Address>()?

Try changing your test to be: partnerDto.Address.Should().BeEquivalentTo(partner.Address, o => o.ComparingByMembers<Address>());

Pilate answered 2/2, 2021 at 10:27 Comment(1)
Actually this solves the issue. @Synonymize posted a good explanation (sadly deleted it since then), suggesting to use o.ComparingByMembers<AddressDto>(), which didn't work. But using Address as parameter does work. I honestly don't understand why; I also thought I'm supposed to parameterize this method with the dto name.Giule
S
11

I think the important part of the docs is:

To determine whether Fluent Assertions should recurs into an object’s properties or fields, it needs to understand what types have value semantics and what types should be treated as reference types. The default behavior is to treat every type that overrides Object.Equals as an object that was designed to have value semantics

Both of your records override Equals, but their Equals methods will only return true if the other object is of the same type. So I think Should().BeEquivalentTo is seeing that your objects implement their own equality, calling into (presumably) AddressDto.Equals which returns false, and then reporting the failure.

It reports the failure using the ToString() versions of the two records, which return { Country = Canada, ZipCode = 12345, City = Toronto, Street = 4 Some street } (for the record without an overridden ToString) and 4 Some street, 12345 Toronto, Canada, (for the object with an overridden ToString).

As the docs say, you should be able to override this by using ComparingByMembers:

partnerDto.Address.Should().BeEquivalentTo(partner.Address,
   options => options.ComparingByMembers<Address>());

or globally:

AssertionOptions.AssertEquivalencyUsing(options => options
    .ComparingByMembers<Address>());
Synonymize answered 2/2, 2021 at 10:32 Comment(3)
That sounds perfectly reasonable to me, but using options => options.ComparingByMembers<AddressDto>() doesn't change the result. :/Giule
Edited to use Address, per Matt Hope's answer. Consider their answer the first, correct, accepted one, and treat mine as adding additional contextSynonymize
Thank you. I also think it's great to have this explanation here, and I indeed accepted Matt Hope's answer, as it was the first. Though, I still have to look into this a bit, because I also thought I'm supposed to parameterize this method with the other type (the DTO).Giule

© 2022 - 2024 — McMap. All rights reserved.