At LeaveWizard.com we are fans of Behaviour Driven Development (BDD) and the use of Gherkin syntax to capture requirements in real business terms. More often than not though, BDD has become synonymous with Selenium and UI driven specification tests. However, I believe that BDD can be equally as valuable at the "unit test" level by redefining our understanding of what a "unit" is and testing the behaviour of the application from it's entry points (API controllers etc) to it's exit points (access to external databases etc). However, there seems to be a distinct lack of documentation on how to achieve this when using .NET 5 and above, so in this post I will explain how you can do this for your own projects.
TL;DR; Just Show Me The Code
If you just want to see the code for this take a look at our sample project on Github. This is an example implementation using the default .NET 5 weather forecast Web API project for demonstration purposes.
What are we trying to achieve?
When testing software we want to make sure that we are testing the expected behaviour of the application rather than the specific implementation details. Test Driven Development (TDD) has tended to lead people towards implementing lots of low-level unit tests (which is great) but over time many people find that the tests can often become a barrier to refactoring code because of the sheer volume of test code that would need to change. This often happens because the tests are tightly coupled to the implementation i.e. they are class level method and property tests. There are situations where this are very useful and I am certainly not suggesting that we shouldn't have any of these lower level tests but we should aim to test the behaviour of code above class level but beneath the UI level. With .NET 5 onwards now enabling self-hosting of MVC and Web API projects it is possible to invoke the controllers as they would be in production and therefore test the behaviour from the API entry points through to the external data dependencies.
Exploring the code
The LeaveWizard.WeatherForecast.Api project is just a sample .NET 5 weather forecast application with the implementation of the GET WeatherForecast endpoint refactored into a provider called WeatherForecastProvider with an interface called IWeatherForecastProvider to enable to us to mock the backend data.
We have also added a LeaveWizard.WeatherForecast.Api.Specs project which contains our specifications for the expected behaviour.
Feature files
The Feature files capture the features that we want to test, in this project there is only a simple feature file stored in the Features folder and looks something like this:
Feature: Weather Prediction
The weather prediction service allows users to predict the weather
Scenario: Can predicate a storm
Given there is a storm coming
When a request is made to predict the weather
Then a storm is correctly predicted
This is the Gherkin syntax that represents the expected behaviour. I'm not too concerned over the "quality" of this feature file/scenario as that is not the purpose of this post. The key thing is that it gives us Given, When and Then steps that we can use as examples.
Given, When, Then
Given, When, Then provides the sentence structure of a scenario, the following briefly describes the expectation of each step type.
Given
The Given step is all about what happened in the past, it should be written in the past tense.
It is used to capture the context under which the scenario is taking place.
The Given step should not execute any real-world code but should instead be used to set up mocks and other context details that will be required in order to support the scenario.
It is important to note that a given can be in any order and by no means dictates a set of instructions.
When
The When step happens in the present. It is an event that occurs that results in some actions that can be observed.
There should be a single When statement per scenario. If you have a scenario such as "When and ", you should consider whether it might be possible to separate this scenario into two separate scenarios.
Then
The Then step is used to define what expected outcomes should be observed
Tying steps to code
Using SpecFlow we can connect the steps in the feature file to code by creating a step file. Examples of these can be found in the Steps folder, here is an example of the weather predication steps file:
[Binding]
public class WeatherPredictionSteps
{
private readonly WebApiDriver _webApiDriver;
private readonly WeatherForecastContext _weatherForecastContext;
public WeatherPredictionSteps(
WebApiDriver webApiDriver,
WeatherForecastContext weatherForecastContext)
{
_webApiDriver = webApiDriver;
_weatherForecastContext = weatherForecastContext;
}
[Given(@"there is a storm coming")]
public void GivenThereIsAStormComing()
{
_weatherForecastContext.PredictAStorm();
}
[When(@"a request is made to predict the weather")]
public void WhenARequestIsMadeToPredictTheWeather()
{
var response = _webApiDriver.ExecuteGet<List<WeatherForecast>>(EndpointRoutes.GetWeatherForecast);
response.StatusCode.Should().Be(HttpStatusCode.OK);
_weatherForecastContext.ReceivedForecast = response.ResponseData;
}
[Then(@"a storm is correctly predicted")]
public void ThenAStormIsCorrectlyPredicted()
{
_weatherForecastContext
.ReceivedForecast
.Should()
.BeEquivalentTo(_weatherForecastContext.PredictedForecast);
}
}
Here we can see the code is tied to the step via the use of relevant Given, When or Then attributes that use regular expressions to match the desired text and run the required code.
Getting the right context
You can have multiple step files, containing multiple steps. The steps do not need to be tied to a particular feature. You can re-use steps across different features, therefore it is important that we do not store state in the step files, otherwise the state will only be available when using those steps. Instead, we want to use a context object. This is simply a POCO that holds data that might be relevant to a given test context, in the above example you can see we have a WeatherForecastContext which simply stores the type of prediction being made and the result of the prediction.
This allows us to pass the context around and have access to this information where ever it is required. For example, in the WeatherForecastProviderFake you can see that we inject the weather forecast context and in this instance simply returns the predicted forecast, however, this could make decisions on what data to return based upon any other information provided by the context object. It would also be possible to inject more contexts if required.
Driving the functionality
You can also see in the above example that we have a WebApiDriver. This contains the logic for actually performing the interaction with the Web API. It owns the interaction with the HttpClient and is responsible for disposing it at the end of the test run. As you can see below, the WebApiDriver accepts an AppHostingContext as a constructor parameter and uses this to create client to be used in the hosting environment.
public class WebApiDriver : IDisposable
{
private readonly AppHostingContext _appHostingContext;
private readonly WebApiContext _webApiContext;
private readonly UserContext _userContext;
private readonly StringBuilder _log = new();
private HttpClient _httpClient;
public HttpClient HttpClient
{
get
{
if (_httpClient == null)
_httpClient = _appHostingContext.CreateClient();
return _httpClient;
}
}
public WebApiDriver(AppHostingContext appHostingContext,
WebApiContext webApiContext,
UserContext userContext)
{
_appHostingContext = appHostingContext;
_webApiContext = webApiContext;
_userContext = userContext;
}
public void Dispose()
{
if (_httpClient != null)
{
_httpClient.Dispose();
_httpClient = null;
}
}
Self-hosting environment
The AppHostingContext is responsible to managing the lifecycle of the self-hosting environment. It uses the SpecFlowWebApplicationFactory to create a self-hosted web application.
The Web Application Factory
The SpecFlowWebApplicationFactory simply uses the built-in .NET 5 host builder functionality to create a SpecFlow specific hosting environment:
public class SpecFlowWebApplicationFactory : WebApplicationFactory<SpecFlowStartup>
{
private readonly IComponentContext _componentContext;
public SpecFlowWebApplicationFactory(IComponentContext componentContext)
{
_componentContext = componentContext;
}
protected override IHostBuilder CreateHostBuilder()
{
var builder = Host.CreateDefaultBuilder()
.UseServiceProviderFactory(x => new AutofacServiceProviderFactory())
.ConfigureWebHostDefaults(x =>
{
x.UseStartup(ctx => new SpecFlowStartup(ctx.Configuration, _componentContext)).UseTestServer();
});
return builder;
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
base.ConfigureWebHost(builder);
builder.ConfigureLogging((context, loggingBuilder) =>
{
loggingBuilder.ClearProviders();
loggingBuilder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<ILoggerProvider, DebugLoggerProvider>());
});
}
#region Debug Logger
#endregion
}
As you can see it uses a special SpecFlowStartup class which inherits from the Startup class used by the weather forecast API project. This overrides key methods such as:
- configuring the MVC environment: registering controllers for dependency injection
- configuring authentication: using our own test version of the auth
- registering additional dependencies: test classes that can be used to override behaviours in the original application
Using Autofac
In this version of the code we are using Autofac for our dependency injection. This is configured in the Program.cs by simply installing the nuget package "Autofac.Extensions.DependencyInjection" and using UseServiceProviderFactory(new AutofacServiceProviderFactory())
. The only other thing you need to do is ensure you add the following code to your Startup file, as this is called "automagically" during start up of the application and allows us to register any required dependencies:
public void ConfigureContainer(ContainerBuilder builder)
{
RegisterDependencies(builder);
}
protected virtual void RegisterDependencies(ContainerBuilder builder)
{
builder.RegisterType<WeatherForecastProvider>().As<IWeatherForecastProvider>();
}
Disposing the host
The last thing we need is to dispose of the hosting environment once we are done. This is done using a SpecFlow hook which simply tells the hosting context to stop the app after the test run is completed.
Summary
By using the project configuration described above we can use SpecFlow to drive our BDD specifications and execute code from the API entry points utilising the self-hosting capabilities of .NET 5. This means that our specifications can invoke the APIs as they would be invoked within a production environment and give us greater test coverage - we can test things like authorisation attributes, filters etc. It also enables us to treat the contents of the API as more of a black box so we do not tie our tests to the actual implementation of the code making them more robust and allowing for easier refactoring in future. We still need to use class level Unit Tests to test the code that requires more detailed assertions but using the SpecFlow specifications at a slightly higher abstraction level without needing to automate the browser gives us a more stable test environment with a greater bag for the buck.
I hope this helps you on your SpecFlow and BDD journey. Feedback always welcome.
Top comments (0)