March 1, 2019

NSpec conventions

NSpec is a BDD testing framework for .NET. Its convention based design results in tests that are less verbose, easier to maintain and produce human-readable output that can be understood by both technical and non-technical stakeholders.

An NSpec test can consist of one or more of the following steps:

  • Context - describes the context under which the test is running
  • Before - sets up the context
  • Act - the action being tested (could also be consided the when in BDD syntax)
  • It - assertions
  • After - test cleanup

There are two ways in which you can declare these steps:

  1. Using the various helper methods/properties available on the nspec base class, for example context, before and it
  2. By declaring regular C# methods that adhere to NSpec conventions

We commonly mix the two approaches which provides a lot of flexibility. In this post we’ll look at the default conventions and how they can be extended.

Assertions

Whilst NSpec can be used to test complex behaviour it can also be used for regular unit testing. By default NSpec will treat any method name prefixed with it or specify as an assertion:

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

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

These will be output like so by the NSpec test runner:

describe nspec conventions
it works (2ms)
specify that it works (0ms)

Before

It’s common to run some setup code before all or each of your tests. For example, you may want to set up mocks once for all tests in your test class but reset some kind of contextual data before each test runs.

Both before_all and before_each methods can be declared to achieve this:

class describe_nspec_conventions : nspec
{
    bool works;
    
    void before_all()
    {
        // Runs once before all tests
    }

    void before_each()
    {
        works = true;
    }
    
    void it_works()
    {
        works.ShouldBeTrue();
    }

    void specify_that_it_works()
    {
        works.ShouldBeTrue();
    }
}

After

To run code after all or each of your tests run, such as to dispose of objects or close database connections, declare an after_all or after_each method:

void after_all()
{
    sessionFactory.Dispose();
}

Act

Perhaps one of the most powerful features of NSpec is the ability to share the “act” step between tests. Often the only thing that changes between our tests is the context, yet with regular test frameworks we usually end up with tests like this:

[Test]
public void Given_some_context_it_works()
{
    SetupContext();
    Act();
    works.ShouldBeTrue();
}

[Test]
public void Given_some_other_context_it_also_works()
{
    SetupOtherContext();
    Act();
    works.ShouldBeTrue();
}

With NSpec’s conventions and using contexts we can remove this duplication:

class describe_nspec_conventions : nspec
{
    string source;
    string result;

    void act_each() => result = source.ToUpper();

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

    void given_a_null_string()
    {
        before = () => source = null;
        it["throws"] = expect<NullReferenceException>();
    }
}

Test runner output:

describe nspec conventions
given a lowercase string
    returns the value in uppercase (6ms)
given a null string
    throws (7ms)

Context

Any method containing an underscore that does not match the rules above will be treated as a context method. Context methods expect assertions (or examples in NSpec terminology) to be defined using it. As such the following test would be ignored:

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

To make this work we need to update the test to use the it step:

void given_a_null_string()
{
    it["works"] = () => true.ShouldBeTrue();
}

Putting it all together

In real applications our tests are typically more complex than the above. Below is an example from a payments system that make use of some of the conventions described above.

public class describe_AuthorizationProcessor : nspec
{
    AuthorizationRequest authorizationRequest;
    AuthorizationResponse authorizationResponse;
    List<IAcquirer> acquirers;
    AuthorizationProcessor processor;

    void before_all() => processor = new AuthorizationProcessor();

    void before_each()
    {
        authorizationRequest = new AuthorizationRequest(10_99, "USD");
        acquirers = new List<IAcquirer>();
    }

    void when_processing()
    {
        act = () => authorizationResponse = processor.AuthorizePayment(authorizationRequest, acquirers);

        context["given authorization request is null"] = () =>
        {
            before = () => authorizationRequest = null;
            it[$"throws {nameof(ArgumentNullException)}"] = expect<ArgumentNullException>();
        };

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

        context["given no acquirers are provided"] = () =>
        {
            before = () => acquirers = new List<IAcquirer>();
            it[$"throws {nameof(ArgumentException)}"] = expect<ArgumentException>();
        };

        context["given one acquirer is provided"] = () =>
        {
            context["and the acquirer declines the payment"] = () =>
            {
                before = () => acquirers.Add(new TestAcquirer(false));
                it["is not approved"] = () => authorizationResponse.Approved.ShouldBeFalse();
                it["returns the acquirer transaction"] = () => authorizationResponse.Transactions.Count.ShouldBe(1);
            };

            context["and the acquirer throws"] = () =>
            {
                before = () => acquirers.Add(new ThrowingAcquirer());
                it["is not approved"] = () => authorizationResponse.Approved.ShouldBeFalse();
            };

            context["and the acquirer approves the payment"] = () =>
            {
                before = () => acquirers.Add(new TestAcquirer(true));
                it["is approved"] = () => authorizationResponse.Approved.ShouldBeTrue();
                it["returns the acquirer transaction"] = () => authorizationResponse.Transactions.Count.ShouldBe(1);
            };
        };

        context["given multiple acquirers are provided"] = () =>
        {
            context["and the first acquirer approves the payment"] = () =>
            {
                before = () => acquirers.AddRange(new[] { new TestAcquirer(true), new TestAcquirer(false) });
                it["is does not cascade"] = () => authorizationResponse.Approved.ShouldBeTrue();
                it["returns the acquirer transaction"] = () => authorizationResponse.Transactions.Count.ShouldBe(1);
            };

            context["and the first acquirer declines the payment"] = () =>
            {
                context["and the response code can not be cascaded"] = () =>
                {
                    before = () => acquirers.AddRange(new[] { new TestAcquirer(false, "20054"), new TestAcquirer(true) });
                    it["is does not cascade"] = () => authorizationResponse.Approved.ShouldBeFalse();
                    it["returns the transaction"] = () => authorizationResponse.Transactions.Count.ShouldBe(1);
                    it["returns the acquirer transaction"] = () => authorizationResponse.Transactions.Count.ShouldBe(1);
                };

                context["and the response code can be cascaded"] = () =>
                {
                    before = () => acquirers.AddRange(new[] { new TestAcquirer(false, ResponseCodes.DoNotHonour), new TestAcquirer(true) });
                    it["cascades to the next acquirer"] = () => authorizationResponse.Approved.ShouldBeTrue();
                    it["returns both acquirer transactions"] = () => authorizationResponse.Transactions.Count.ShouldBe(2);
                };
            };
        };
    }
}

Output

describe AuthorizationProcessor
when processing
    given authorization request is null
    throws ArgumentNullException (6ms)
    given acquirers is null
    throws ArgumentNullException (0ms)
    given no acquirers are provided
    throws ArgumentException (0ms)
    given one acquirer is provided
    and the acquirer declines the payment
        is not approved (2ms)
        returns the acquirer transaction (5ms)
    and the acquirer throws
        is not approved (0ms)
    and the acquirer approves the payment
        is approved (0ms)
        returns the acquirer transaction (0ms)
    given multiple acquirers are provided
    and the first acquirer approves the payment
        is does not cascade (0ms)
        returns the acquirer transaction (0ms)
    and the first acquirer declines the payment
        and the response code can not be cascaded
        is does not cascade (0ms)
        returns the transaction (0ms)
        returns the acquirer transaction (0ms)
        and the response code can be cascaded
        cascades to the next acquirer (0ms)
        returns both acquirer transactions (0ms)

Summary

NSpec can take some getting used to, especially for developers who have only worked with traditional unit testing frameworks or who have not experienced BDD. By leveraging its convention based approach you can write tests that are less vebose, have less duplication and more clearly express the requirements/expectations of the software.

In the next post I’ll cover how to customise the default NSpec conventions to enhance the development experience.

© 2022 Ben Foster