Leaky
Leaky

Reputation: 3636

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

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; }
    }
}

Upvotes: 12

Views: 12442

Answers (2)

canton7
canton7

Reputation: 42245

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>());

Upvotes: 12

Matt Hope
Matt Hope

Reputation: 331

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

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

Upvotes: 11

Related Questions