Search code examples
c#unit-testingassertxunitshouldly

How can I have custom asserts with Shouldly and maintain the call-site-specific assertion messages?


I'm using the excellent Shouldly library in my xUnit tests and I'm finding myself using the set sequence of assertions in different tests, so I'm combining them into new assertion extension methods - but when I do this I lose Shouldly's contextual assertion messages.

Here's my old code, which works with Shouldly to include source-level information and call-site context in the Shouldly assertion error:

[Fact]
public void Dict_should_contain_valid_Foobar_Bar_entry()
{
    IDictionary<String,Bar> dict = ...
    dict.TryGetValue( "Foobar", out Bar bar ).ShouldBeTrue();
    bar.ShouldNotBeNull();
    bar.ChildList.Count.ShouldBe( expected: 3 );
    bar.Message.ShouldBeNull();
}

[Fact]
public void Dict_should_contain_valid_Barbaz_Bar_entry()
{
    IDictionary<String,Bar> dict = ...
    dict.TryGetValue( "Barbaz", out Bar bar ).ShouldBeTrue();
    bar.ShouldNotBeNull();
    bar.ChildList.Count.ShouldBe( expected: 3 );
    bar.Message.ShouldBeNull();
}

I converted it to this new extension method in the same project:

public static void ShouldBeValidBar( this IDictionary<String,Bar> dict, String barName )
{
    dict.ShouldNotBeNull();
    dict.TryGetValue( barName, out Bar bar ).ShouldBeTrue();
    bar.ShouldNotBeNull();
    bar.ChildList.Count.ShouldBe( expected: 3 );
    bar.Message.ShouldBeNull();
}

And so my tests are changed to this:

[Fact]
public void Dict_should_contain_valid_Foobar_Bar_entry()
{
    IDictionary<String,Bar> dict = ...
    dict.ShouldBeValidBar( "Foobar" );
}

[Fact]
public void Dict_should_contain_valid_Barbaz_Bar_entry()
{
    IDictionary<String,Bar> dict = ...
    dict.ShouldBeValidBar( "Barbaz" );
}

...but now my Shouldly assert messages don't contain any contextual information from Dict_should_contain_valid_Foobar_Bar_entry and instead only contains context from ShouldBeValidBar.

How can I instruct Shouldly to disregard the context of ShouldBeValidBar and to use its parent call-site instead?


Solution

  • TL;DR:

    Add the [ShouldlyMethods] attribute to your custom assert extension method classes (not individual extension methods):

    [ShouldlyMethods] // <-- This, right here!
    public static class MyShouldlyAssertions
    {
        public static void ShouldBeValidBar( this IDictionary<String,Bar> dict, String barName )
        {
            [...]
        }
    }
    

    Long version:

    After some googling, and reading articles about how Shouldly works - and after reading the source of Shouldly's secret-sauce: SourceCodeTextGetter, I see that it determines which entries in the stack-trace can be overlooked by the presence of the [ShouldlyMethods] attribute (Shouldly.ShouldlyMethodsAttribute) on the method's containing type in each frame of the stack-trace:

    void ParseStackTrace(StackTrace trace)
    {
        [...]
    
        while (ShouldlyFrame == null || currentFrame.GetMethod().IsShouldlyMethod())
        {
            if (currentFrame.GetMethod().IsShouldlyMethod())
                ShouldlyFrame = currentFrame;
    
            [...]
        }
    
        [...]
    }
    
    internal static bool IsShouldlyMethod(this MethodBase method)
    {
        if (method.DeclaringType == null)
            return false;
    
        return
            method
                .DeclaringType
                .GetCustomAttributes( typeof(ShouldlyMethodsAttribute), true )
                .Any()
            ||
            (
                method.DeclaringType.DeclaringType != null
                && 
                method
                    .DeclaringType
                    .DeclaringType
                    .GetCustomAttributes( typeof(ShouldlyMethodsAttribute), true )
                    .Any()
            );
    }
    

    So it's just a matter of adding the [ShouldlyMethods] attribute to my extension methods' container classes:

    [ShouldlyMethods]
    public static class ShouldlyAssertionExtensions
    {    
        public static void ShouldBeValidBar( this IDictionary<String,Bar> dict, String barName )
        {
            dict.ShouldNotBeNull();
            dict.TryGetValue( barName, out Bar bar ).ShouldBeTrue();
            bar.ShouldNotBeNull();
            bar.ChildList.Count.ShouldBe( expected: 3 );
            bar.Message.ShouldBeNull();
        }
    }
    

    And now my assert errors have the context of the call-site of ShouldBeValidBar. Hurrah!