2

I'm using a library called Optional (https://github.com/nlkl/Optional) that allows the "maybe" abstraction that is common to functional languages.

The library is awesome, but I'm facing a problem regarding testing: I can't test correctly whether 2 optional instances are equivalent or not.

To test for equivalence I'm using Fluent Assertions. However, I'm not getting the desired results.

I will illustrate the problem with code:

#load "xunit"

[Fact]
void TestOptional()
{
    var a = new[] { 1, 2, 3 }.Some();
    var b = new[] { 1, 2, 3 }.Some();
    
    a.Should().BeEquivalentTo(b);
}

This test fails, as I show in the screenshot (I'm using LINQPad, for convenience)

enter image description here

As you see, this isn't what one expects.

How do I tell Fluent Assertions to check the equivalence correctly using the Option type?

SuperJMN
  • 13,110
  • 16
  • 86
  • 185

1 Answers1

4

UPDATE

I opened an issue on Github regarding your problem and yesterday a pull request was merged, so the next (pre-)release should enable you to solve your problem elegantly:

The new overloads allows you to use an open generic type. If both an open and closed type are specified, the closed type takes precedence.

SelfReferenceEquivalencyAssertionOptions adds the following methods:

  • public TSelf ComparingByMembers(System.Type type) { }
  • public TSelf ComparingByValue(System.Type type) { }

Here's the unit test that was added to Fluent Assertions showing how it works:

[Fact]
public void When_comparing_an_open_type_by_members_it_should_succeed()
{
    // Arrange
    var subject = new Option<int[]>(new[] { 1, 3, 2 });
    var expected = new Option<int[]>(new[] { 1, 2, 3 });

    // Act
    Action act = () => subject.Should().BeEquivalentTo(expected, opt => opt
        .ComparingByMembers(typeof(Option<>)));

    // Assert
    act.Should().NotThrow();
}

Fluent Assertions - Object Graph Comparison says:

Value Types

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. Unfortunately, anonymous types and tuples also override this method, but because we tend to use them quite often in equivalency comparison, we always compare them by their properties.

You can easily override this by using the ComparingByValue<T> or ComparingByMembers<T> options for individual assertions

Option<T> is a struct and overrides Equals, so Fluent Assertions compares a and b with value semantics.

Option<T> implements Equals like this:

public bool Equals(Option<T> other)
{
  if (!this.hasValue && !other.hasValue)
    return true;
  return this.hasValue
    && other.hasValue 
    && EqualityComparer<T>.Default.Equals(this.value, other.value);
}

Thus int[] is compared by reference and your test fails.

You can override this behavior for each test individually, like Guro Stron said:

a.Should().BeEquivalentTo(b, opt => opt.ComparingByMembers<Option<int[]>>());

Or globally via static AssertionOptions class:

AssertionOptions.AssertEquivalencyUsing(options => 
    options.ComparingByMembers<Option<int[]>>());

edit:

For your case Fluent Assertions would need an AssertEquivalencyUsing override that supports unbound generic types:

AssertionOptions.AssertEquivalencyUsing(options => 
    options.ComparingByMembers(typeof(Option<>)));

No such override exists, unfortunately.

Another solution a came up with would be an extension method. Here a very simplistic implementation:

public static class FluentAssertionsExtensions
{
    public static void BeEquivalentByMembers<TExpectation>(
        this ComparableTypeAssertions<TExpectation> actual,
        TExpectation expectation)
    {
        actual.BeEquivalentTo(
            expectation,
            options => options.ComparingByMembers<TExpectation>());
    }
}
Michael Schnerring
  • 3,584
  • 4
  • 23
  • 53
  • Thanks, but this is still a far from ideal solution. It means that I will have to configure every different type of collection I'd want to handle. I mean, if want it to work with List, I need to explicitly add the line above for List, right? – SuperJMN Sep 16 '20 at 14:51
  • 1
    Yes, you would need to add that line. In your case it may be undesired but in other cases it isn't - there isn't an ideal solution. – Michael Schnerring Sep 16 '20 at 15:33
  • 1
    @SuperJMN check my updated answer for another solution I came up with. – Michael Schnerring Sep 16 '20 at 16:22
  • 1
    @SuperJMN Updated my post again. Yesterday a PR was merged, that adds support for open generic types. – Michael Schnerring Sep 20 '20 at 18:44