Search code examples
c#.netfluent-assertions

FluentAssertions AssertionScope Context SubPath


Is there anyway to specify what gets appended to the {context} key of the built in assertions or potentially get the value of that context?

Sample

E.g. I managed to append to custom Assertions using AssertionSope keys to custom Execution.Assertions as follows:

Sample Classes

public class Foo
{
    public Bar Bar { get; } = new Bar();
}

public class Bar
{
    public bool IsFull {get; set; }
}

Extension Classes

public static class Extensions
{
    public static FooAssertions Should(this Foo foo) => new FooAssertions(foo);
    public static BarAssertions Should(this Bar bar) => new BarAssertions(bar);

    public class FooAssertions
    {
        public Foo Subject { get; }

        public FooAssertions(Foo subject)
        {
            Subject = subject;
        }

        [CustomAssertion]
        public void HaveFullBar(string because = null, params object[] becauseArgs)
        {
            using (var scope = new AssertionScope())
            {
                scope.AddReportable("SubPath", "." + nameof(Foo.Bar));

                Subject.Bar.Should().BeFull(because, becauseArgs);
            }
        }
    }

    public class BarAssertions
    {
        public Bar Subject { get; }

        public BarAssertions(Bar subject)
        {
            Subject = subject;
        }

        [CustomAssertion]
        public void BeFull(string because = null, params object[] becauseArgs)
        {
            Execute.Assertion
                .ForCondition(Subject.IsFull)
                .BecauseOf(because, becauseArgs)
                .FailWith("Expected {context}{SubPath}.IsFull to be {0}{reason} but found {1}", true, Subject.IsFull);
        }
    }
}

Test Method

    [TestMethod]
    public void CustomTest()
    {
        var foo = new Foo();

        foo.Should().HaveFullBar();
    }

Output

Output: Expected foo.Bar.IsFull to be True but found False

Question

How can I append to the build in {context} so that I don't have to re-invent the wheel for every single assertion FluentAssertion already provides?

E.g. calling the standard BooleanAssertions.Should().BeTrue():

        [CustomAssertion]
        public void BeFull(string because = null, params object[] becauseArgs)
        {
            Subject.IsFull.Should().BeTrue();
        }

but this loses the SubPath of .Bar.IsFull and outputs:

 Output: Expected foo to be true, but found False.

Attempts

Attempt One

Tried to set the "context" of inner assertion scopes to some value, but it loses the previous values!

        [CustomAssertion]
        public void HaveFullBar(string because = null, params object[] becauseArgs)
        {
            using (new AssertionScope(".Bar"))
            {
                Subject.Bar.Should().BeFull(because, becauseArgs);
            }
        }

        [CustomAssertion]
        public void BeFull(string because = null, params object[] becauseArgs)
        {
            using (new AssertionScope(".IsFull"))
            {
                Subject.IsFull.Should().BeTrue();
            }
        }

This just returns .IsFull which is the most inner AssertionScope:

Expected .IsFull to be true, but found False.

Attempt Two

Tried to add "{context}" into the constructor of AssertionScope

        [CustomAssertion]
        public void HaveFullBar(string because = null, params object[] becauseArgs)
        {
            using (new AssertionScope("{context}.Bar"))
            {
                Subject.Bar.Should().BeFull(because, becauseArgs);
            }
        }
        [CustomAssertion]
        public void BeFull(string because = null, params object[] becauseArgs)
        {
            using (new AssertionScope("{context}.IsFull"))
            {
                Subject.IsFull.Should().BeTrue();
            }
        }

This just outputs the most inner {context}.IsFull

 Expected {context}.IsFull to be true, but found False.

Attempt Three

        [CustomAssertion]
        public void HaveFullBar(string because = null, params object[] becauseArgs)
        {
            using (new AssertionScope(AssertionScope.Current.Context + ".Bar"))
            {
                Subject.Bar.Should().BeFull(because, becauseArgs);
            }
        }

        [CustomAssertion]
        public void BeFull(string because = null, params object[] becauseArgs)
        {
            using (var scope = new AssertionScope(AssertionScope.Current.Context + ".IsFull"))
            {
                Subject.IsFull.Should().BeTrue();
            }
        }

Still loses original context, as the first AssertionScope.Current.Context is null;

Output:

Expected .Bar.IsFull to be true, but found False.

Solution

  • To answer your question specifically, the value you're looking for is in the CallerIdentity property of AssertionScope. (Sort of... after some digging it seems that it's not really a property but rather a static method call that extracts the caller text from the current stack trace, which is why you need to save the value right away.)

    There are a couple different approaches here. Part of the issue is in using Subject. By using Subject you effectively start a new Should() chain even though you are working within one already. So one option is to not use Subject and instead use this.

    But that seems to limit what you can assert. If you need to use Subject in order to "chain" assertions deeper into the object graph (which there may be alternative ways of doing), then to append to the context looks something like the following:

    [CustomAssertion]
    public void MyAssertion(
      string because = "",
      params object[] becauseArgs
    ) {
      using (var scope = new AssertionScope()) {
        // save because it seems to be lost later
        var identity = scope.CallerIdentity;
    
        // Context is a Lazy<string> so has to be written like this
        // (could write .ToLazy())
        scope.Context = new Lazy<string>(() => $"{identity}.{nameof(Subject.Member)}");
          
        Subject.Member.Should().Be(something); // maybe pass because, etc.
    
        // can be repeated for other members
        scope.Context = new Lazy<string>(() => $"{identity}.{nameof(Subject.Member2)}");
          
        Subject.Member2.Should().Be(somethingElse);
      }
    }
    

    Output is something like:

    Expected caller.Member1 to be <expected>, but found <actual>.
    Expected caller.Member2 to be <expected2>, but found <actual2>.
    

    Update:

    Cleaned up a bit this can look something like this. Maybe there are better approaches, but this is what I came up with after some trial and error. (It would be nice if this could be made 'fluent' but I'm not sure if it's possible or not.)

    // put this in a library somewhere
    // this is necessary to be able to use CallerArgumentExpressionAttribute
    // or else we could use an Action<Action<...>>
    public sealed class SubjectAsserter {
       readonly AssertionScope mScope;
       readonly string mIdentity;
    
       public SubjectAsserter(
          AssertionScope scope,
          string identity
       ) {
          mScope = scope;
          mIdentity = identity;
       }
    
       public void Assert<T>(
          T member,
          Action<T> assertion,
          string subjectName = nameof(ObjectAssertions.Subject),
          [CallerArgumentExpression("member")] string memberInfo = null!
       ) {
          var prefix = subjectName + ".";
          if (!memberInfo.StartsWith(prefix))
             throw new InvalidOperationException();
    
          var suffix = memberInfo[prefix.Length..];
          mScope.Context = $"{mIdentity}.{suffix}".ToLazy();
          assertion(member);
       }
    }
    
    [CustomAssertion]
    public void MyAssertion(
       string value1,
       int value2
    ) {
       // this can't be simplified unfortunately
       // while "CallerIdentity" is claiming to be a property of AssertionScope
       // it is really a static method call that inspects the current call stack
       // accessing it anywhere else will result in a different/wrong result
       using var scope = new AssertionScope();
       var asserter = new SubjectAsserter(scope, scope.CallerIdentity);
    
       asserter.Assert(Subject.Member1, x => x.Should().Be(value1));
       asserter.Assert(Subject.Member2, x => x.Should().Be(value2));
    
       // added this example since I figured out how to get it to work
       asserter.Assert(
          Subject.Invoking(x => x.DoSomething()),
          x => x.Should().ThrowExactly<InvalidOperationException>()
       );
    }
    

    Output should be the same as before: (note that ThrowExactly returns an extra message when in an AssertionScope that is slightly wrong but still informative.)

    Expected caller.Member1 to be <expected>, but found <actual>.
    Expected caller.Member2 to be <expected2>, but found <actual2>.
    Expected System.InvalidOperationException, but no exception was thrown.
    Expected caller.Invoking(x => x.DoSomething()) to be System.InvalidOperationException, but found <null>.