SpecFlow + .NET BDD Testing: Complete Setup Guide
SpecFlow is the .NET implementation of the Cucumber/Gherkin BDD framework. It integrates directly with MSTest, NUnit, and xUnit, making it a natural fit for .NET teams that want to practise behaviour-driven development without leaving the ecosystem.
This guide covers everything from NuGet package installation through a working test suite with Playwright for browser testing and a GitHub Actions CI pipeline.
What Is SpecFlow?
SpecFlow translates Gherkin .feature files into runnable tests. You write plain-English scenarios; SpecFlow generates test stubs that bind to C# methods decorated with [Given], [When], and [Then] attributes. When you run dotnet test, SpecFlow executes the bound methods and reports pass/fail per scenario.
The commercial SaaS product "LivingDoc" (Tricentis) adds test analytics, but you don't need it — everything in this guide uses the free open-source library.
Project Setup
Create a test project
dotnet new classlib -n MyApp.AcceptanceTests
cd MyApp.AcceptanceTestsAdd NuGet packages
# Core SpecFlow packages
dotnet add package SpecFlow
dotnet add package SpecFlow.xUnit <span class="hljs-comment"># or SpecFlow.NUnit / SpecFlow.MsTest
dotnet add package SpecFlow.Tools.MsBuild.Generation
<span class="hljs-comment"># xUnit runner
dotnet add package xunit
dotnet add package xunit.runner.visualstudio
dotnet add package Microsoft.NET.Test.Sdk
<span class="hljs-comment"># Playwright for browser tests
dotnet add package Microsoft.Playwright
dotnet add package FluentAssertionsConfigure the project file
Open MyApp.AcceptanceTests.csproj and ensure it targets a test SDK:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SpecFlow" Version="3.9.74" />
<PackageReference Include="SpecFlow.xUnit" Version="3.9.74" />
<PackageReference Include="SpecFlow.Tools.MsBuild.Generation" Version="3.9.74" />
<PackageReference Include="xunit" Version="2.7.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="Microsoft.Playwright" Version="1.42.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup>
</Project>specflow.json (optional but recommended)
{
"$schema": "https://specflow.org/specflow-config.json",
"language": {
"feature": "en"
},
"generator": {
"allowDebugGeneratedFiles": false
},
"runtime": {
"missingOrPendingStepsOutcome": "Error"
}
}Directory Structure
MyApp.AcceptanceTests/
├── Features/
│ ├── Login.feature
│ └── Cart.feature
├── Steps/
│ ├── LoginSteps.cs
│ └── CartSteps.cs
├── Support/
│ ├── Hooks.cs ← @BeforeScenario / @AfterScenario
│ └── TestContext.cs ← shared state
└── specflow.jsonWriting Feature Files
Create Features/Login.feature:
Feature: User Login
As a registered user
I want to authenticate with my credentials
So that I can access my account
Background:
Given the application is running
@smoke
Scenario: Successful login with valid credentials
When I navigate to the login page
And I enter email "alice@example.com" and password "secret123"
And I submit the login form
Then I should be redirected to the dashboard
And I should see "Welcome, Alice" in the header
@regression
Scenario: Login rejected with incorrect password
When I navigate to the login page
And I enter email "alice@example.com" and password "wrongpassword"
And I submit the login form
Then I should see the error "Invalid email or password"
And I should remain on the login page
Scenario Outline: Login with multiple accounts
When I navigate to the login page
And I enter email "<email>" and password "<password>"
And I submit the login form
Then I should be redirected to the dashboard
Examples:
| email | password |
| alice@example.com | secret123 |
| bob@example.com | pass456 |Shared Context
Create Support/TestContext.cs to hold state across steps:
using Microsoft.Playwright;
namespace MyApp.AcceptanceTests.Support;
public class TestContext
{
public IBrowser Browser { get; set; } = null!;
public IPage Page { get; set; } = null!;
public string BaseUrl { get; set; } = "https://your-app.example.com";
public IAPIRequestContext? ApiContext { get; set; }
}SpecFlow uses dependency injection (via its built-in BoDi container) to inject TestContext into any step class and hook class that declares it in the constructor.
Hooks
Create Support/Hooks.cs for setup and teardown:
using BoDi;
using Microsoft.Playwright;
using MyApp.AcceptanceTests.Support;
using TechTalk.SpecFlow;
namespace MyApp.AcceptanceTests;
[Binding]
public class Hooks
{
private readonly IObjectContainer _container;
private IPlaywright? _playwright;
public Hooks(IObjectContainer container)
{
_container = container;
}
[BeforeScenario]
public async Task BeforeScenario(ScenarioContext scenarioContext)
{
_playwright = await Playwright.CreateAsync();
var browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true,
Args = new[] { "--no-sandbox", "--disable-dev-shm-usage" }
});
var page = await browser.NewPageAsync();
var ctx = new TestContext
{
Browser = browser,
Page = page
};
// Register in DI container so step classes can access it
_container.RegisterInstanceAs(ctx);
}
[AfterScenario]
public async Task AfterScenario(ScenarioContext scenarioContext, TestContext ctx)
{
if (scenarioContext.TestError != null)
{
// Save screenshot on failure
var screenshotPath = $"screenshots/{scenarioContext.ScenarioInfo.Title.Replace(" ", "_")}.png";
await ctx.Page.ScreenshotAsync(new PageScreenshotOptions { Path = screenshotPath });
}
await ctx.Browser.CloseAsync();
_playwright?.Dispose();
}
}Step Definitions
Create Steps/LoginSteps.cs:
using FluentAssertions;
using Microsoft.Playwright;
using MyApp.AcceptanceTests.Support;
using TechTalk.SpecFlow;
namespace MyApp.AcceptanceTests.Steps;
[Binding]
public class LoginSteps
{
private readonly TestContext _ctx;
public LoginSteps(TestContext ctx)
{
_ctx = ctx;
}
[Given(@"the application is running")]
public async Task GivenApplicationRunning()
{
var response = await _ctx.Page.GotoAsync($"{_ctx.BaseUrl}/health");
response!.Ok.Should().BeTrue("application health check failed");
}
[When(@"I navigate to the login page")]
public async Task WhenNavigateLogin()
{
await _ctx.Page.GotoAsync($"{_ctx.BaseUrl}/login");
await _ctx.Page.WaitForSelectorAsync("#email");
}
[When(@"I enter email ""(.*)"" and password ""(.*)""")]
public async Task WhenEnterCredentials(string email, string password)
{
await _ctx.Page.FillAsync("#email", email);
await _ctx.Page.FillAsync("#password", password);
}
[When(@"I submit the login form")]
public async Task WhenSubmitLogin()
{
await _ctx.Page.ClickAsync("button[type='submit']");
}
[Then(@"I should be redirected to the dashboard")]
public async Task ThenRedirectedDashboard()
{
await _ctx.Page.WaitForURLAsync("**/dashboard**");
_ctx.Page.Url.Should().Contain("/dashboard");
}
[Then(@"I should see ""(.*)"" in the header")]
public async Task ThenSeeTextInHeader(string expectedText)
{
var header = await _ctx.Page.TextContentAsync("header");
header.Should().Contain(expectedText);
}
[Then(@"I should see the error ""(.*)""")]
public async Task ThenSeeError(string expectedError)
{
await _ctx.Page.WaitForSelectorAsync(".error-alert");
var errorText = await _ctx.Page.TextContentAsync(".error-alert");
errorText.Should().Contain(expectedError);
}
[Then(@"I should remain on the login page")]
public void ThenRemainOnLoginPage()
{
_ctx.Page.Url.Should().EndWith("/login");
}
}Running Tests
# Install Playwright browsers (first time only)
pwsh bin/Debug/net8.0/playwright.ps1 install chromium
<span class="hljs-comment"># Run all scenarios
dotnet <span class="hljs-built_in">test
<span class="hljs-comment"># Run only smoke tag
dotnet <span class="hljs-built_in">test --filter <span class="hljs-string">"Category=smoke"
<span class="hljs-comment"># Run a specific feature
dotnet <span class="hljs-built_in">test --filter <span class="hljs-string">"FullyQualifiedName~Login"
<span class="hljs-comment"># Run with verbosity
dotnet <span class="hljs-built_in">test --logger <span class="hljs-string">"console;verbosity=detailed"Using Data Tables
Given the following products are in the catalogue:
| Name | Price | Stock |
| Wireless Mouse | 29.99 | 150 |
| Mechanical Keyboard | 89.99 | 75 |
| USB Hub | 19.99 | 300 |[Given(@"the following products are in the catalogue:")]
public void GivenProductsInCatalogue(Table table)
{
foreach (var row in table.Rows)
{
var name = row["Name"];
var price = decimal.Parse(row["Price"]);
var stock = int.Parse(row["Stock"]);
// Seed the database or call an API
_productService.Create(name, price, stock);
}
}
// Or convert the whole table to a list of objects:
[Given(@"the following products are in the catalogue:")]
public void GivenProductsFromTable(Table table)
{
var products = table.CreateSet<Product>();
foreach (var product in products)
{
_productService.Create(product);
}
}API Testing with SpecFlow
SpecFlow works equally well for API tests — no browser needed:
Scenario: Create product via API
Given I am authenticated as an admin
When I POST to "/api/products" with:
"""json
{
"name": "Widget Pro",
"price": 49.99,
"stock": 100
}
"""
Then the response status should be 201
And the response body should contain "id"[When(@"I POST to ""(.*)"" with:")]
public async Task WhenPostTo(string endpoint, string requestBody)
{
_ctx.ApiContext = await _playwright!.APIRequest.NewContextAsync(new()
{
BaseURL = _ctx.BaseUrl,
ExtraHTTPHeaders = new Dictionary<string, string>
{
["Authorization"] = $"Bearer {_ctx.AuthToken}"
}
});
_ctx.Response = await _ctx.ApiContext.PostAsync(endpoint, new()
{
DataObject = System.Text.Json.JsonSerializer.Deserialize<object>(requestBody)
});
}Generating Living Documentation
SpecFlow's community tool generates HTML living documentation from your feature files and test results:
dotnet tool install --global SpecFlow.Plus.LivingDoc.CLI
# After running tests with TRX output:
dotnet <span class="hljs-built_in">test --logger <span class="hljs-string">"trx;LogFileName=TestResults.trx"
<span class="hljs-comment"># Generate living doc
livingdoc test-assembly MyApp.AcceptanceTests.dll \
-t TestResults/TestResults.trx \
--output LivingDoc.htmlOpen LivingDoc.html to browse all features and scenarios with pass/fail status — useful for sharing with product managers.
GitHub Actions CI
name: Acceptance Tests
on: [push, pull_request]
jobs:
specflow:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: "8.0.x"
- name: Install dependencies
run: dotnet restore MyApp.AcceptanceTests/MyApp.AcceptanceTests.csproj
- name: Build
run: dotnet build --no-restore
- name: Install Playwright browsers
run: pwsh MyApp.AcceptanceTests/bin/Debug/net8.0/playwright.ps1 install --with-deps chromium
- name: Run SpecFlow tests
run: |
mkdir -p screenshots
dotnet test --no-build \
--logger "trx;LogFileName=TestResults.trx" \
--filter "Category!=slow"
- name: Publish test results
uses: dorny/test-reporter@v1
if: always()
with:
name: SpecFlow Results
path: "**/TestResults/*.trx"
reporter: dotnet-trx
- name: Upload screenshots
uses: actions/upload-artifact@v4
if: failure()
with:
name: failure-screenshots
path: screenshots/Common Mistakes
Missing [Binding] attribute: Every step class and hook class must be decorated with [Binding]. Without it, SpecFlow won't discover your step definitions.
Ambiguous step definitions: If two [When] patterns can match the same step text, SpecFlow throws an AmbiguousStepDefinitionException. Make patterns specific or use parameter capture groups to differentiate.
Not awaiting async steps: SpecFlow supports async Task step methods from version 3.4+. If you use async void, exceptions won't propagate correctly — always return Task.
Regenerating code-behind files: SpecFlow generates .feature.cs files during build. If they get out of sync (e.g. after a merge conflict), delete them and rebuild to regenerate.
Next Steps
With SpecFlow running:
- Add SpecFlow+ Runner for parallel execution across feature files
- Integrate Reqnroll (the community fork of SpecFlow, actively maintained as of 2024) if your team needs the latest Gherkin features
- Add Allure reporting with
SpecFlow.Allureplugin for richer analytics - Use SpecFlow.Assist.Dynamic for flexible table-to-object conversion without needing strongly-typed model classes
SpecFlow brings the full Gherkin BDD workflow to .NET with minimal friction — the same scenarios that your product team reviews in Confluence or JIRA execute as automated acceptance tests in your CI pipeline.