fluentassertions / fluentassertions

A very extensive set of extension methods that allow you to more naturally specify the expected outcome of a TDD or BDD-style unit tests. Targets .NET Framework 4.7, as well as .NET Core 2.1, .NET Core 3.0, .NET 6, .NET Standard 2.0 and 2.1. Supports the unit test frameworks MSTest2, NUnit3, XUnit2, MSpec, and NSpec3.
https://www.fluentassertions.com
Apache License 2.0
3.78k stars 549 forks source link

WhenTypesAre #1304

Open jnyrup opened 4 years ago

jnyrup commented 4 years ago

When comparing two object graphs using BeEquivalentTo the equivalency of identically named and typed properties can be overridden using the Using<T>+WhenTypeIs<T> combo.

var subject = new
{
    Date = new DateTime(2020, 04, 10)
};

var expected = new
{
    Date = new DateTime(2020, 04, 11)
};

subject.Should().BeEquivalentTo(expected, opt => opt
    .Using<DateTime>(ctx => ctx.Subject.Should().BeCloseTo(ctx.Expectation, 1.Days()))
    .WhenTypeIs<DateTime>()
);

I'd like to propose a more generalized version, WhenTypesAre<TSubject, TExpectation> that can be applied when the subject and expectation are of different types. This is useful when e.g. a class and its DTO equivalent have differently typed properties.

My current toy example is:

var subject = new
{
    Property = 42
};

var expectation = new
{
    Property = "42"
};

Action act = () => subject.Should().BeEquivalentTo(expectation, options => options
    .Using<int, string>(ctx => ctx.Subject.ToString().Should().Be(ctx.Expectation))
    .WhenTypesAre<int, string>());

act.Should().NotThrow();

My current prototype has the following relevant API changes

public abstract class SelfReferenceEquivalencyAssertionOptions<TSelf>
{
+    public Restriction<TSubjectProperty, TExpectationProperty> Using<TSubjectProperty, TExpectationProperty>(Action<IAssertionContext<TSubjectProperty, TExpectationProperty>> action)
} 

public class Restriction<TMember>
{
-   public TSelf When(Expression<Func<IMemberInfo, bool>> predicate) { }
+   public TSelf When(Expression<Func<IEquivalencyValidationContext, bool>> predicate) { }
}

public class Restriction<TSubjectMember, TExpectationMember>
{
+     public TSelf When(Expression<Func<IEquivalencyValidationContext, bool>> predicate) { }
+     public TSelf WhenTypesAre<TSubjectMemberType, TExpectationMemberType>() { }
}

It uses IEquivalencyValidationContext over IMemberInfo to get access to get the runtime type of IEquivalencyValidationContext.Expectation.

As IEquivalencyValidationContext implements IMemberInfo this change should not give any compile time breakage. The question is if we want to expose IEquivalencyValidationContext.

dennisdoomen commented 4 years ago

The question is if we want to expose IEquivalencyValidationContext.

It already is exposed since it's part of the IEquivalencyStep interface.

Action act = () => subject.Should().BeEquivalentTo(expectation, options => options
   .Using<int, string>(ctx => ctx.Subject.ToString().Should().Be(ctx.Expectation))
   .WhenTypesAre<int, string>());

I think we should consider dropping the WhenTypeIs/Are construct alltogether, so

Action act = () => subject.Should().BeEquivalentTo(expectation, options => options
    .Using<int, string>(ctx => ctx.Subject.ToString().Should().Be(ctx.Expectation)));
jnyrup commented 4 years ago

It already is exposed since it's part of the IEquivalencyStep interface.

Ohh, I meant exposing the entire IEquivalencyValidationContext interface in the When method.

I think we should consider dropping the WhenTypeIs/Are construct alltogether

Hmm... That would drop support for restricting the matching of properties via SelectedMemberPath and SelectedMemberInfo.

dennisdoomen commented 4 years ago

Ohh, I meant exposing the entire IEquivalencyValidationContext interface in the When method.

I would not expose anything that we really need to expose. That keeps the maintainability focus in check.

That would drop support for restricting the matching of properties via SelectedMemberPath and SelectedMemberInfo.

Since the Using call happens nested inside a Should().BeEquivalentTo() call, calling Using<T> or Using<TS, TE> could apply an implicit When restricting to the types involved. We can still support an additional chained When that overrides/extends this expression.

jnyrup commented 4 years ago

Seems to work out quite well with an implicit conversion from Restriction to TSelf 👍

One case I haven't figured out how we should handle: When Expectation is null, the RuntimeType falls back to CompileTimeType. For Subject we can't do the same, as we do not store its CompileTimeType. That has the implication that we cannot distinguish nulls, i.e. if the Subject was a (string)null or (int?)null.

var subject = new
{
    Property = (int?)null
};

var expectation = new
{
    Property = ""
};

subject.Should().BeEquivalentTo(expectation, opt => opt
    .Using<string, string>(ctx => ...));
dennisdoomen commented 4 years ago

That has the implication that we cannot distinguish nulls, i.e. if the Subject was a (string)null or (int?)null.

I don't get the problem.