10

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; }
    }
}
Leaky
  • 3,088
  • 2
  • 26
  • 35
  • Can you [edit] the question to include the definitions of `Address` and `AddressDto`? – canton7 Feb 02 '21 at 10:02
  • Sure thing, @canton7; just a moment – Leaky Feb 02 '21 at 10:04
  • What is the type of `partnerDto` variable? And how is`Address` property defined? – Pavel Anikhouski Feb 02 '21 at 10:17
  • @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`. – Leaky Feb 02 '21 at 10:23
  • 1
    @Leaky we'll need all these definitions to be able to reproduce your problem – Pavel Anikhouski Feb 02 '21 at 10:25
  • Okay, I'll add all of them, just a sec – Leaky Feb 02 '21 at 10:28
  • Added them. Sorry, it took a while, since I had to remove some parts for brevity. – Leaky Feb 02 '21 at 10:39
  • 2
    FYI, there is an open issue about this https://github.com/fluentassertions/fluentassertions/issues/1451 – Jonas Nyrup Feb 02 '21 at 11:18

2 Answers2

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

Matt Hope
  • 311
  • 2
  • 3
  • 1
    Actually this solves the issue. @canton7 posted a good explanation (sadly deleted it since then), suggesting to use `o.ComparingByMembers()`, 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. – Leaky Feb 02 '21 at 10:49
8

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>());
canton7
  • 37,633
  • 3
  • 64
  • 77
  • That sounds perfectly reasonable to me, but using `options => options.ComparingByMembers()` doesn't change the result. :/ – Leaky Feb 02 '21 at 10:45
  • 1
    Edited to use `Address`, per Matt Hope's answer. Consider their answer the first, correct, accepted one, and treat mine as adding additional context – canton7 Feb 02 '21 at 10:51
  • 1
    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). – Leaky Feb 02 '21 at 10:56