March 12, 2019

Customising NSpec

In my previous post I covered the default conventions of NSpec, a BDD testing framework for .NET. In this post we’ll look at how you can customise NSpec.

Customising conventions

Our NSpec test projects are simply console applications that invoke the NSpec runner like so:

public override bool Execute(RunnerOptions options)
{
    var types = Assembly.GetEntryAssembly().GetTypes();
    var filter = GetSpecFilter(options.TestClass);
    var finder = new SpecFinder(types, filter);
    var tagsFilter = new Tags().Parse(options.TagsFlag);
    var builder = new ContextBuilder(finder, tagsFilter, new DefaultConventions());
    var runner = new ContextRunner(tagsFilter, GetFormatter(options), false);
    
    var results = runner.Run(builder.Contexts().Build());
    return !results.Failures().Any();
}

As the name might suggest, the ContextBuilder is responsible for building the test contexts and it uses a default set of conventions to identify test steps based on method names.

The good news is that you can customise these conventions to fit your requirements. In my previous post I explained how you can write a simple unit-style test in NSpec like so:

void it_works()
{
    true.ShouldBeTrue();
}

In cases where the context can be inferred, this is fine. However, if you want to use given syntax to describe your context you would need to write your test like so:

void given_a_lowercase_string()
{
    before = () => source = "hello";
    act = () => result = source.ToUpper();
    it["returns the value in uppercase"] = () => result.ShouldBe("HELLO");
}

This would output the result:

describe nspec conventions
given a lowercase string
    returns the value in uppercase (2ms)

In cases where you have a single context, it may be more desirable to declare your test like so:

void given_a_lowercase_string_it_returns_the_value_in_uppercase()
{
    "hello".ToUpper().ShouldBe("HELLO");
}

To do this we need to tell NSpec to treat this method as an example rather than a context. We can do this by creating our own conventions class:

public class CustomConventions : DefaultConventions
{
    public override void SpecifyConventions(ConventionSpecification specification)
    {
        base.SpecifyConventions(specification);
        specification.SetExample(
            new Regex("(^it_)|(^specify_)|(^should_)|(^given_.*_(it|should)_)", RegexOptions.IgnoreCase));
    }
}

This extends the example definition from the default conventions to treat any method starting with given that also contains it or should as a context. We can now provide this custom convention when running NSpec:

public override bool Execute(RunnerOptions options)
{
    ... // As above
    var builder = new ContextBuilder(finder, tagsFilter, new CustomConventions());
    ... // As above
}

Which will produce the following output for our test:

describe nspec conventions
given a lowercase string it returns the value in uppercase (3ms)

Extending nspec

NSpec will discover any test class that ultimately derives from the nspec base class.

This means you can create your own base class that adds additional functionality to NSpec. You can do this as a drop in replacement without having to update any of your existing tests:

public abstract class nspec : global::NSpec.nspec
{

}

Adding aliases

You can use the above approach to extend the NSpec grammar. For example, if you want to use when rather than act to declare your test action:

public abstract class nspec : global::NSpec.nspec
{       
    public virtual Action when
    {
        get => act;
        set => act = value;
    }
}

Now we can write the following test:

void given_a_lowercase_string()
{
    before = () => source = "hello";
    when = () => result = source.ToUpper();
    it["returns the value in uppercase"] = () => result.ShouldBe("HELLO");
}

NSpec actually uses the same technique for the describe alias that can be used in place of context:

describe["acquirers is null"] = () =>
{
    before = () => acquirers = null;
    it[$"throws {nameof(ArgumentNullException)}"] = expect<ArgumentNullException>();
};

Debugging

Tooling is very limited in NSpec and it currently does not integrate with Visual Studio or VS Code (although my team are making progress in this area). I personally don’t mind working in a terminal but even then, there are times when the ability to debug and step into a test is paramount. To debug specific tests we can leverage NSpec’s tagging feature and apply a special “debug” tag by convention:

public abstract class nspec : global::NSpec.nspec
{
    private const string DebugTag = "debug";

    public nspec()
    {
        _it = new ActionRegister((name, tags, action) => it[name, DebugTag] = action);
    }
    
    public ActionRegister _it;
}

Now whenever we declare an assertion using _it it will be tagged as debug. We can then update launch.json in Visual Studio Code to support debugging the tests:

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": ".NET Core Launch (console)",
            "type": "coreclr",
            "request": "launch",
            "preLaunchTask": "build",
            "program": "${workspaceFolder}/bin/Debug/netcoreapp2.2/NSpecFtw.dll",
            "args": [
                "-t",
                "debug"
            ],
            "cwd": "${workspaceFolder}",
            "console": "internalConsole",
            "stopAtEntry": false,
            "internalConsoleOptions": "openOnSessionStart"
        },
        {
            "name": ".NET Core Attach",
            "type": "coreclr",
            "request": "attach",
            "processId": "${command:pickProcess}"
        }
    ]
}

Now to debug a test, prefix your it assertions with an underscore, then Debug:

_it["returns the value in uppercase"] = () => result.ShouldBe("HELLO");

© 2022 Ben Foster