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:
- Using the various helper methods/properties available on the
nspec
base class, for examplecontext
,before
andit
- 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.