SpecFlow Static HttpClient — CI Race Condition Fix
Static HttpClient in SpecFlow causes random CI failures when parallel scenarios mutate BaseAddress.
20+ years shipping production .NET services in enterprise systems. Written from production experience, not tutorials.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- SpecFlow translates plain-English Gherkin feature files into executable .NET test methods
- Step definitions bind each Gherkin step to C# assertions using regex or SpecFlow Expressions
- Scenario Outline + Examples tables generate one test per data row; Background runs setup before every scenario
- Context injection via BoDi provides thread-safe, per-scenario shared state — avoid static fields entirely
- Always install SpecFlow.Tools.MsBuild.Generation: missing it yields zero tests with zero diagnostics
- Worst production gotcha: ambiguous step definitions surface only at runtime, not at build time
SpecFlow is the de facto BDD (Behavior-Driven Development) framework for .NET, bridging the gap between human-readable Gherkin feature files and executable C# test code. It solves the fundamental problem of keeping living documentation in sync with automated tests by parsing plain-text scenarios (Given/When/Then) and binding them to step definitions via attributes like [Given], [When], and [Then].
In practice, this means product owners write acceptance criteria in Gherkin, developers implement the glue code, and the test runner (typically NUnit, xUnit, or MSTest) executes those scenarios as real integration or unit tests. SpecFlow generates a test class per scenario, so each scenario runs as an independent test in your CI pipeline — which is exactly where the static HttpClient race condition bites you.
In the .NET ecosystem, SpecFlow competes with Reqnroll (a community fork) and xBehave.net, but SpecFlow remains the most widely adopted due to its mature Visual Studio/ReSharper integration and extensive hook system. You should NOT use SpecFlow when your team lacks buy-in for BDD ceremonies (three amigos, scenario workshops) — the overhead of maintaining Gherkin files outweighs benefits if stakeholders never read them.
For pure unit testing without collaboration, stick to xUnit/NUnit with FluentAssertions. Where SpecFlow shines is in acceptance test suites that double as documentation, especially for microservices with complex business rules where a static HttpClient instance can silently corrupt state across scenarios due to concurrent test execution.
Imagine you're building a vending machine with your team. The business manager writes on a sticky note: 'Given the machine has a Coke, When I press B2 and insert $1.50, Then I get the Coke and no change.' A developer then writes code that makes each line of that sticky note actually run as a test. That's BDD with SpecFlow — the sticky note is a Gherkin feature file, and the code that brings it to life is a step definition. The business and the developer are literally reading the same sentence.
Most test suites are a black box that only developers can read. A QA engineer files a bug, a product manager writes acceptance criteria in Confluence, a developer writes a unit test — and none of these three artefacts ever talk to each other. Regressions slip through because the test suite tests what was built, not what was agreed. That gap between specification and verification is where production bugs are born.
Behaviour-Driven Development (BDD) closes that gap by making the specification executable. SpecFlow is the .NET implementation of Cucumber's BDD framework, and it lets you write plain-English feature files in Gherkin syntax that double as automated integration tests. Instead of translating requirements into xUnit tests manually — and inevitably losing nuance — you write the requirement once in a format every stakeholder can read, then bind it to C# code that actually exercises your system. When the scenario passes, the requirement is met. Full stop.
By the end of this article you'll know how to scaffold a SpecFlow project from scratch, write robust Gherkin scenarios including Scenario Outlines and Background, wire step definitions with regex and expression capture groups, manage shared state safely with dependency injection, hook into the test lifecycle with Before/After hooks, and sidestep the six most painful production gotchas — including the ambiguous step definition trap and the context injection anti-pattern that silently shares mutable state across parallel test runs.
What SpecFlow + C# Actually Does for BDD
SpecFlow is the .NET binding for Gherkin, the structured natural-language DSL that drives Behavior-Driven Development (BDD). It translates plain-text feature files into executable C# test methods via step definitions. The core mechanic: you write scenarios in Given-When-Then syntax, and SpecFlow's test runner maps each step to a C# method decorated with [Given], [When], or [Then] attributes. This decouples business-readable specs from implementation, letting non-technical stakeholders validate behavior without reading code.
At runtime, SpecFlow parses feature files, matches step text against regex or Cucumber Expressions in your bindings, and invokes the corresponding C# methods. Scenarios can be parameterized, share state via dependency injection (e.g., SpecFlow's built-in container), and run in parallel across multiple threads. The critical property: each scenario gets its own context scope, so shared static state—like an HttpClient—can cause race conditions when tests run concurrently in CI.
Use SpecFlow when your team needs a single source of truth for acceptance criteria that both business and engineering can read. It shines in regulated environments (finance, healthcare) where audit trails of test coverage against requirements are mandatory. But the real value is forcing explicit, testable contracts between layers—if a step is hard to write, the design is probably wrong.
Visual BDD Lifecycle: From Feature File to Test Runner
Before we dive into code, it's important to understand the full lifecycle of a BDD test in SpecFlow. From the moment you write a Gherkin feature file to the moment the test runner reports pass/fail, several layers interact:
- Feature File – Written in Gherkin syntax (.feature file). Contains Feature, Background, Scenario, Scenario Outline, Examples.
- Code Generation – At build time, SpecFlow's MsBuild generator parses the .feature file and emits a .feature.cs partial class with an
[NUnit.Framework.Test]or[Fact]method per scenario. - Step Definition Resolution – When a test runs, SpecFlow's runtime engine reads the emitted test method, steps through each Gherkin step, and matches it to a registered step definition method using regex or SpecFlow Expressions.
- Context Injection – BoDi (SpecFlow's built-in IoC container) resolves constructor parameters of step definition classes. Each scenario gets a fresh scope, so injected objects are isolated per scenario.
- Hooks –
[BeforeScenario]and[AfterScenario]run before/after each scenario, outside step definitions. Feature-level hooks also exist. - Test Runner – xUnit/NUnit takes over, runs the generated test method, which internally calls step definitions. Assertions inside step definitions determine pass/fail.
The diagram below visualises this flow:
Scaffolding a SpecFlow Project That Won't Embarrass You in Code Review
Before you write a single Gherkin line, you need a project structure that scales. The common mistake is adding SpecFlow to an existing xUnit or NUnit project and letting feature files pile up in the root. That works until you have 200 scenarios and no way to run a subset.
The recommended layout separates concerns cleanly: one project for your production code, one dedicated SpecFlow test project, and a shared contracts project if multiple test projects need the same interfaces. SpecFlow 3.9+ targets .NET 6/7/8 and ships as a set of NuGet packages — the runner (SpecFlow.xUnit, SpecFlow.NUnit, or SpecFlow.MsTest), the core (SpecFlow), and the code generator (SpecFlow.Tools.MsBuild.Generation).
The code generator is the secret engine. At build time it reads every .feature file and generates a .feature.cs file alongside it — a partial class that contains a standard xUnit/NUnit test method per scenario. You never edit that generated file. Your step definitions live in separate classes. This separation means the Gherkin stays human-readable while the plumbing stays out of sight.
Install SpecFlow.xUnit and SpecFlow.Tools.MsBuild.Generation together. Missing the MsBuild package is mistake #1 — your feature files silently produce no tests and the test runner reports zero discoveries.
# ── Terminal: scaffold the solution ────────────────────────────────────────── # 1. Create a blank solution dotnet new sln -n VendingMachine # 2. Production code project dotnet new classlib -n VendingMachine.Core -o src/VendingMachine.Core dotnet sln add src/VendingMachine.Core/VendingMachine.Core.csproj # 3. SpecFlow test project (xUnit runner) dotnet new xunit -n VendingMachine.Specs -o tests/VendingMachine.Specs dotnet sln add tests/VendingMachine.Specs/VendingMachine.Specs.csproj # 4. Reference production code from the test project dotnet add tests/VendingMachine.Specs reference src/VendingMachine.Core # 5. Add SpecFlow NuGet packages dotnet add tests/VendingMachine.Specs package SpecFlow dotnet add tests/VendingMachine.Specs package SpecFlow.xUnit dotnet add tests/VendingMachine.Specs package SpecFlow.Tools.MsBuild.Generation # ── tests/VendingMachine.Specs/VendingMachine.Specs.csproj ──────────────────── # After adding packages, verify this appears in the .csproj: # # <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" /> # </ItemGroup> # # SpecFlow also needs this property to generate .feature.cs files: # <GenerateAssemblyInfo>false</GenerateAssemblyInfo> ← only if you hit duplicate attribute errors # 6. Build — the MsBuild generator runs here and produces *.feature.cs files dotnet build # ── Folder layout after setup ───────────────────────────────────────────────── # VendingMachine/ # ├── src/ # │ └── VendingMachine.Core/ ← production code # │ └── VendingMachineService.cs # └── tests/ # └── VendingMachine.Specs/ # ├── Features/ ← .feature files live here # │ └── Dispensing.feature # ├── StepDefinitions/ ← C# step binding classes # │ └── DispensingSteps.cs # └── Support/ ← hooks, context objects, DI setup # ├── Hooks.cs # └── VendingMachineContext.cs
Gherkin Deep Dive: Scenarios, Outlines, and Background That Actually Model Reality
Gherkin has six keywords you'll use daily: Feature, Background, Scenario, Scenario Outline, Examples, and the step keywords Given/When/Then/And/But. The novice writes one Scenario per happy path and calls it done. The senior uses the full toolkit to model edge cases without duplicating prose.
Background runs its steps before every Scenario in the file. Use it for setup that truly applies to every single scenario — like pre-loading a product catalogue. Don't abuse it as a dumping ground for unrelated setup, or scenarios become impossible to understand in isolation.
Scenario Outline is the data-driven workhorse. You write one scenario template with angle-bracket placeholders and provide an Examples table. SpecFlow generates a separate test method per row. This is far cleaner than a loop inside a step definition because each row is a first-class, individually-named test — failures point to the exact row.
The And and But keywords inherit the keyword of the preceding step for readability. Under the hood SpecFlow treats them identically. The distinction is purely for human readers — it reads like a sentence, not a list.
One deep detail: Gherkin scenarios should describe observable behaviour from the outside, not implementation steps. 'When I call the CalculateChange() method' is a bad scenario. 'When I insert $2.00 into a machine priced at $1.50' is a good scenario. The test should survive a complete internal rewrite.
# tests/VendingMachine.Specs/Features/Dispensing.feature Feature: Vending Machine Dispensing As a thirsty customer I want to insert money and select a product So that I receive my drink and the correct change # Background runs before EVERY scenario in this file. # Use it only for setup that genuinely applies to all scenarios. Background: Given the vending machine is stocked with the following products | ProductCode | Name | PriceInCents | Quantity | | A1 | Cola | 150 | 5 | | B2 | Water | 100 | 3 | | C3 | OrangeJuice | 200 | 0 | # ── Happy path ──────────────────────────────────────────────────────────── Scenario: Customer receives product and exact change Given the customer inserts 200 cents When the customer selects product "A1" Then the customer receives "Cola" And the customer receives 50 cents change And the machine stock for "A1" decreases by 1 # ── Out-of-stock guard ───────────────────────────────────────────────────── Scenario: Customer selects an out-of-stock product Given the customer inserts 200 cents When the customer selects product "C3" Then the customer receives an out-of-stock error And the customer receives 200 cents change # ── Insufficient funds ──────────────────────────────────────────────────── Scenario: Customer inserts too little money Given the customer inserts 50 cents When the customer selects product "A1" Then the customer receives an insufficient funds error But the machine retains 50 cents # ── Data-driven with Scenario Outline ───────────────────────────────────── # Each row in Examples becomes a separate, independently-named test method. # Placeholders in <angle brackets> are substituted per row. Scenario Outline: Change calculation across multiple price points Given the customer inserts <InsertedCents> cents When the customer selects product "<ProductCode>" Then the customer receives "<ExpectedProduct>" And the customer receives <ExpectedChange> cents change Examples: | InsertedCents | ProductCode | ExpectedProduct | ExpectedChange | | 150 | A1 | Cola | 0 | | 200 | A1 | Cola | 50 | | 200 | B2 | Water | 100 | | 300 | B2 | Water | 200 |
Gherkin Syntax Cheat Sheet
This table summarises all Gherkin keywords and their usage in SpecFlow. Keep it handy when writing feature files:
| Keyword | Purpose | Example / Notes | ||
|---|---|---|---|---|
Feature | High-level description of a feature | Feature: Vending Machine Dispensing — Should include a business-readable description typically on the next line. | ||
Background | Steps that run before every scenario in the file | Use for shared setup like loading product data. Avoid overuse. | ||
Scenario | A single concrete test case | Scenario: Customer receives product and exact change | ||
Given | Preconditions / state setup | Given the vending machine is stocked — Executed first. | ||
When | Action the user performs | When the customer selects product "A1" — The trigger. | ||
Then | Expected outcome | Then the customer receives "Cola" — Assertions go here. | ||
And / But | Additional steps of the same type (Given/When/Then) | And the customer receives 50 cents change — Inherits the keyword of the preceding step. | ||
Scenario Outline | Data-driven template with placeholders | Scenario Outline: Change calculation paired with Examples: table. | ||
Examples | Data table for Scenario Outline | Each row generates a separate test. Headers become placeholders. | ||
# | Comment (ignored) | # This is a comment — Use sparingly; scenarios should be self-explanatory. | ||
@tag | Tag for filtering and hooks | @smoke @slow — Filters in CLI: dotnet test --filter "Category=smoke" | ||
| ` | ` | Data tables (inline) | Used in Given steps to pass structured data. Mapped via SpecFlow.Assist. | |
""" | Doc Strings (multi-line) | Used for JSON, XML, or long text. Rare in practice; prefer data tables. |
Pro tip: Gherkin is case-insensitive for keywords, but by convention they are capitalised. Use descriptive language — scenarios should be readable by non-developers.
And and But as separate step types, leading to ambiguous matches. Stick to Given/When/Then for the first step of each phase, then And for subsequent steps of the same kind. This keeps the pattern matching predictable.Scenario Outline + Examples for data-driven tests, Background sparingly, and @tags for test organisation.Scenario Outline vs Data Table: When to Use Which
Both Scenario Outlines and inline Data Tables (Table in step definitions) allow data-driven testing, but they serve different purposes. Choosing the wrong one leads to either duplicative scenarios or step definitions that are hard to maintain.
Scenario Outline + Examples – Use when the data drives different expected outcomes. Each row becomes a separate test with its own name. The entire scenario template is repeated for each row. Ideal for boundary testing, input/output pairs, and combinatorial cases where you want each test to be individually reported.
Inline Data Table – Use when the data is a single set of input for a step, not a set of test cases. For example, providing a list of products to initialise the machine. The step definition maps the table to objects using CreateSet<T>. This does NOT create multiple test methods — only one scenario runs, but it may iterate over the data internally.
Decision Matrix
| Criteria | Scenario Outline + Examples | Inline Data Table (SpecFlow.Assist) |
|---|---|---|
| Test generation | Each Examples row → separate test method | Single test method, data used within step |
| Reporting | Failures pinpoint exact row | Failure at scenario level; must add context to know which row failed |
| Step definition complexity | Single template step with placeholders; parameters passed automatically | Need to write CreateSet<T>() and sometimes manual iteration |
| Readability for non-devs | Very high — entire scenario readable | Less intuitive; the table is just one step's input |
| Best for | Testing multiple input/output combinations that should all pass | Supplying a collection of objects for setup (e.g., initial stock) |
| Example | Testing change for different coin amounts | Loading product catalogue from a spreadsheet |
| Performance | More test methods, but each is fast | One test method, may be slower if data is large |
Rule of thumb: If the data changes the expected result, use Scenario Outline. If the data is merely input for a single behaviour, use an inline table.
Step Definitions, Regex Capture, and Shared State with Context Injection
A step definition is a C# method decorated with [Given], [When], or [Then] and a pattern string. When SpecFlow's step binder sees a Gherkin step, it regex-matches it against every registered pattern and invokes the winner. Understanding this matching engine is what separates a SpecFlow beginner from someone who can maintain a 1,000-scenario suite.
SpecFlow supports two pattern styles: regular expressions and SpecFlow Expressions (formerly Cucumber Expressions). SpecFlow Expressions use curly-brace type hints like {int} and {string} and are usually cleaner. Under the hood they still compile to regex. Use regex directly only when you need look-aheads, optional groups, or other constructs the expression syntax doesn't cover.
Shared state is the hardest problem. Scenarios have multiple steps, and those steps often need to share objects — the machine instance, the dispensing result, the error that was thrown. Global static fields are a disaster in parallel runs. SpecFlow's answer is ScenarioContext and context injection. Prefer context injection: declare a plain C# class as your shared container, accept it in multiple step definition constructors, and SpecFlow's built-in BoDi IoC container creates one instance per scenario and injects it wherever needed. It's disposed after the scenario ends — clean every time.
Hooks ([BeforeScenario], [AfterScenario], [BeforeFeature] etc.) let you plug into the lifecycle without polluting step definitions with setup logic. They're the right place to spin up an in-memory database, start a WireMock server, or reset a singleton between scenarios.
// ── src/VendingMachine.Core/VendingMachineService.cs ───────────────────────── namespace VendingMachine.Core; public record Product(string Code, string Name, int PriceInCents, int Quantity); public class DispensingResult { public Product? DispensedProduct { get; init; } public int ChangeInCents { get; init; } public string? ErrorMessage { get; init; } public bool Success => ErrorMessage is null; } public class VendingMachineService { // Mutable stock dictionary — keyed by product code private readonly Dictionary<string, Product> _stock; private int _insertedCents; public VendingMachineService(IEnumerable<Product> initialStock) { _stock = initialStock.ToDictionary(p => p.Code); } public void InsertMoney(int cents) => _insertedCents += cents; public DispensingResult SelectProduct(string productCode) { if (!_stock.TryGetValue(productCode, out var product)) return new DispensingResult { ErrorMessage = "UNKNOWN_PRODUCT", ChangeInCents = _insertedCents }; if (product.Quantity == 0) { // Return all money — machine never keeps money for out-of-stock int refund = _insertedCents; _insertedCents = 0; return new DispensingResult { ErrorMessage = "OUT_OF_STOCK", ChangeInCents = refund }; } if (_insertedCents < product.PriceInCents) return new DispensingResult { ErrorMessage = "INSUFFICIENT_FUNDS" }; int change = _insertedCents - product.PriceInCents; _insertedCents = 0; // Decrement stock — records are immutable so we replace the entry _stock[productCode] = product with { Quantity = product.Quantity - 1 }; return new DispensingResult { DispensedProduct = product, ChangeInCents = change }; } public int GetStockQuantity(string productCode) => _stock.TryGetValue(productCode, out var p) ? p.Quantity : 0; public int RetainedCents => _insertedCents; } // ── tests/VendingMachine.Specs/Support/VendingMachineContext.cs ─────────────── // This is the shared-state bag injected across step definition classes. // One instance per scenario — created and disposed by SpecFlow's BoDi container. namespace VendingMachine.Specs.Support; using VendingMachine.Core; public class VendingMachineContext { // The system under test — populated by Background steps public VendingMachineService? Machine { get; set; } // The result of the most recent SelectProduct call public DispensingResult? LastResult { get; set; } // Track initial quantities so we can assert decrements public Dictionary<string, int> InitialQuantities { get; } = new(); } // ── tests/VendingMachine.Specs/StepDefinitions/DispensingSteps.cs ───────────── namespace VendingMachine.Specs.StepDefinitions; using TechTalk.SpecFlow; using TechTalk.SpecFlow.Assist; // for table.CreateSet<T>() using VendingMachine.Core; using VendingMachine.Specs.Support; using Xunit; // [Binding] tells SpecFlow to scan this class for step definitions and hooks. [Binding] public sealed class DispensingSteps { // Context injected by BoDi — same instance shared with any other step class // that also accepts VendingMachineContext in its constructor this scenario. private readonly VendingMachineContext _ctx; public DispensingSteps(VendingMachineContext ctx) { _ctx = ctx; } // ── Background step ────────────────────────────────────────────────────── // Table.CreateSet<T>() uses the column headers as property names (case-insensitive). // It handles int conversion automatically via the Assist library. [Given(@"the vending machine is stocked with the following products")] public void GivenTheMachineIsStockedWithProducts(Table stockTable) { var products = stockTable.CreateSet<Product>(); // maps rows → Product records _ctx.Machine = new VendingMachineService(products); // Snapshot initial quantities so assertions can check decrements foreach (var product in products) _ctx.InitialQuantities[product.Code] = product.Quantity; } // ── Given steps ────────────────────────────────────────────────────────── // {int} is a SpecFlow Expression — matches one or more digits, auto-converts to int. [Given(@"the customer inserts {int} cents")] public void GivenCustomerInsertsMoney(int cents) { _ctx.Machine!.InsertMoney(cents); } // ── When steps ─────────────────────────────────────────────────────────── // {string} matches a double-quoted string in Gherkin and strips the quotes. [When(@"the customer selects product {string}")] public void WhenCustomerSelectsProduct(string productCode) { // Store result on context so Then steps can assert against it _ctx.LastResult = _ctx.Machine!.SelectProduct(productCode); } // ── Then steps ─────────────────────────────────────────────────────────── [Then(@"the customer receives {string}")] public void ThenCustomerReceivesProduct(string expectedProductName) { Assert.NotNull(_ctx.LastResult); Assert.True(_ctx.LastResult.Success, $"Expected success but got error: {_ctx.LastResult.ErrorMessage}"); Assert.Equal(expectedProductName, _ctx.LastResult.DispensedProduct?.Name); } [Then(@"the customer receives {int} cents change")] public void ThenCustomerReceivesChange(int expectedChangeInCents) { Assert.Equal(expectedChangeInCents, _ctx.LastResult!.ChangeInCents); } [Then(@"the customer receives an out-of-stock error")] public void ThenCustomerReceivesOutOfStockError() { Assert.Equal("OUT_OF_STOCK", _ctx.LastResult!.ErrorMessage); } [Then(@"the customer receives an insufficient funds error")] public void ThenCustomerReceivesInsufficientFundsError() { Assert.Equal("INSUFFICIENT_FUNDS", _ctx.LastResult!.ErrorMessage); } // "But" steps use [Then] — SpecFlow treats But identically to Then at runtime. [Then(@"the machine retains {int} cents")] public void ThenMachineRetainsMoney(int expectedRetainedCents) { Assert.Equal(expectedRetainedCents, _ctx.Machine!.RetainedCents); } [Then(@"the machine stock for {string} decreases by {int}")] public void ThenStockDecreases(string productCode, int decrementAmount) { int initial = _ctx.InitialQuantities[productCode]; int current = _ctx.Machine!.GetStockQuantity(productCode); Assert.Equal(decrementAmount, initial - current); } } // ── tests/VendingMachine.Specs/Support/Hooks.cs ─────────────────────────────── // Hooks that run outside the step definitions — clean lifecycle management. namespace VendingMachine.Specs.Support; using TechTalk.SpecFlow; [Binding] public sealed class Hooks { // [BeforeScenario] runs before every scenario. // If you need to reset external state (e.g. a real DB), do it here. // The VendingMachineContext is already fresh per-scenario via BoDi, // so we only need this hook if we have truly external resources. [BeforeScenario] public void LogScenarioStart(ScenarioContext scenarioContext) { Console.WriteLine($"[START] {scenarioContext.ScenarioInfo.Title}"); } [AfterScenario] public void LogScenarioResult(ScenarioContext scenarioContext) { var status = scenarioContext.TestError is null ? "PASSED" : "FAILED"; Console.WriteLine($"[{status}] {scenarioContext.ScenarioInfo.Title}"); // In a real suite you'd flush logs, take screenshots, or reset DB here. } }
Hooks Execution Order in SpecFlow
SpecFlow hooks allow you to run code at specific points in the test lifecycle. Understanding the exact execution order is critical when you need to set up external resources (e.g., database, WireMock) and tear them down reliably.
The following table lists all hooks in execution order, from the most global to the most specific. Hooks with the same scope execute in the order they are registered (by class file name alphabetically within the assembly). You can also attach tags to hooks to limit them to specific tagged scenarios.
| Hook Attribute | Scope | When It Runs | Use Case |
|---|---|---|---|
[BeforeTestRun] | Once per test run | Before any feature file is executed | Start a global container, set up shared mock servers, initialise logging. Runs once only. Must be static. |
[AfterTestRun] | Once per test run | After all features have completed | Clean up global resources. Runs once only. Must be static. |
[BeforeFeature] | Once per feature file | Before any scenario in the feature runs | Feature-level setup (e.g., creating a database schema for the feature). Can be static or instance. |
[AfterFeature] | Once per feature file | After all scenarios in the feature have run | Clean up feature-level resources. |
[BeforeScenario] | Once per scenario | Before the first Given step of the scenario | Per-scenario setup: create a clean context, reset state, start transaction. Most commonly used. |
[AfterScenario] | Once per scenario | After the last Then step, even if scenario fails | Per-scenario cleanup: rollback transaction, log results, capture screenshot on failure. |
[BeforeScenarioBlock] | Per step block (Given/When/Then) | Before the first step of each block | Rarely used; can be used to log timing of phases. |
[AfterScenarioBlock] | Per step block | After the last step of the block | Rarely used. |
[BeforeStep] | Per individual step | Before each step definition method | Very granular; use for step-level logging or retries. |
[AfterStep] | Per individual step | After each step definition method | Step-level cleanup or diagnostic screenshots. |
Order of execution: BeforeTestRun → BeforeFeature → BeforeScenario → BeforeScenarioBlock → BeforeStep → Step → AfterStep → AfterScenarioBlock → AfterScenario → AfterFeature → AfterTestRun.
Note: Hooks in the same scope (e.g., two [BeforeScenario] methods) run in alphabetical order of the class name containing them. To enforce a specific order, you can use [BeforeScenario(Order = 1)] (SpecFlow 3.9+ supports Order property). Lower numbers run first.
[AfterFeature] but forgetting that it won't run if the feature crashes entirely (e.g., a compilation error in a step definition). Always ensure cleanup code is resilient to failures, and consider using try/finally in static hooks. For critical-state cleanup, use [AfterTestRun] as a safety net.[BeforeScenario] for per-scenario setup and [AfterScenario] for per-scenario teardown. Use the Order attribute to sequence hooks of the same scope.Parallel Execution, Tags, and Production-Grade Configuration
By default SpecFlow with xUnit runs scenarios in parallel at the feature level — one feature file per thread. That sounds great until you realise your step definitions share a static HttpClient or a database connection string that's mutated per-scenario. Parallelism exposes every shared-mutable-state bug you haven't found yet.
The safest model: mark your test assembly with [assembly: CollectionBehavior(CollectionBehavior.CollectionPerClass)] to control xUnit's parallelism, then let SpecFlow manage scenario-level isolation via context injection. Never use static fields in step definition or context classes. If you need a database, spin up a separate schema per scenario using a GUID suffix on the schema name.
Tags let you slice the test suite. A Scenario tagged @smoke runs in your fast pre-deploy pipeline. A Scenario tagged @slow runs nightly. You can filter from the CLI: dotnet test --filter 'Category=smoke'. Tags also let Hooks fire selectively — [BeforeScenario("smoke")] only runs for @smoke-tagged scenarios. This is how you attach WireMock or a real API key only where needed.
The specflow.json configuration file controls runtime behaviour — output plugin, step assembly scanning, and (crucially) whether missing steps are treated as Pending or Inconclusive. Set missingOrPendingStepsOutcome to Error in production pipelines so an unimplemented step fails the build immediately rather than being silently skipped.
// ── tests/VendingMachine.Specs/specflow.json ────────────────────────────────── // This file controls SpecFlow runtime behaviour. // Place it in the test project root alongside the .csproj. /* { "$schema": "https://specflow.org/specflow-default.json", "language": { "feature": "en" }, "bindingCulture": { "name": "en-US" }, "runtime": { "missingOrPendingStepsOutcome": "Error" } } */ // ↑ missingOrPendingStepsOutcome: "Error" means an unbound step breaks the build. // Change to "Pending" locally during active development so you can run partial suites. // ── tests/VendingMachine.Specs/AssemblyInfo.cs ──────────────────────────────── // Controls xUnit's parallelism — must sit at assembly level. using Xunit; // CollectionPerAssembly: all test classes share one thread unless you opt individual // classes into a named Collection. Use CollectionPerClass for full scenario isolation. [assembly: CollectionBehavior(CollectionBehavior.CollectionPerClass, DisableTestParallelization = false)] // ── Feature file with tags ──────────────────────────────────────────────────── // Dispensing.feature (tag examples shown inline as comments) // // @smoke ← applies to all scenarios below until next tag group // Scenario: Customer receives product and exact change // ... // // @slow @integration // Scenario: Customer sees stock sync with warehouse API // ... // ── CLI: run only @smoke scenarios ─────────────────────────────────────────── // dotnet test tests/VendingMachine.Specs --filter 'Category=smoke' // ── tests/VendingMachine.Specs/Support/SmokeHooks.cs ───────────────────────── // Hook that fires ONLY for scenarios tagged @smoke namespace VendingMachine.Specs.Support; using TechTalk.SpecFlow; [Binding] public sealed class SmokeHooks { private readonly ScenarioContext _scenarioContext; public SmokeHooks(ScenarioContext scenarioContext) { _scenarioContext = scenarioContext; } // The string argument to [BeforeScenario] is a tag filter. // This method only executes when the current scenario carries @smoke. [BeforeScenario("smoke")] public void ConfigureSmokeEnvironment() { Console.WriteLine($"Smoke setup for: {_scenarioContext.ScenarioInfo.Title}"); // e.g. set a lighter timeout, skip WireMock, use in-memory repos } // ── Parallel isolation demo ─────────────────────────────────────────────── // BAD — static field will cause race conditions in parallel runs: // private static VendingMachineService? _sharedMachine; // ← NEVER DO THIS // GOOD — injected context is per-scenario, thread-safe: // private readonly VendingMachineContext _ctx; // ← injected via constructor } // ── Advanced: registering custom services in BoDi ──────────────────────────── // If you need interfaces (e.g. IProductRepository), register them in a BeforeScenario hook. [Binding] public sealed class DependencyRegistration { private readonly IObjectContainer _container; // BoDi's IoC container public DependencyRegistration(IObjectContainer container) { _container = container; } [BeforeScenario] public void RegisterScenarioDependencies() { // Register a fake repository for every scenario. // BoDi will inject IProductRepository wherever it's requested in step classes. // Swap for a real implementation in @integration-tagged scenarios. _container.RegisterTypeAs<InMemoryProductRepository, IProductRepository>(); } } // Marker interface and stub implementation — just enough to show the pattern public interface IProductRepository { /* ... */ } public class InMemoryProductRepository : IProductRepository { /* ... */ }
Data-Driven Testing with SpecFlow.Assist: Table Manipulation and Production Pitfalls
SpecFlow.Assist is the built-in library for turning Gherkin tables into C# objects. The two workhorses are CreateSet<T>() and CreateInstance<T>(). CreateSet returns a collection of objects, one per table row. CreateInstance returns a single object from a single-row table (header + one row of values). The mapping is convention-based: column headers must match property names (case-insensitive by default).
Here's the trap: if a column header doesn't match any property name, CreateSet silently ignores it — no error, no warning. Your test runs with null or default values, and you might not notice until the scenario fails for a different reason. Production teams find this when a refactored model property name breaks tests silently. The fix is to enable strict mode: call CreateSet<T>(PrototypingBehavior.Strict) and SpecFlow throws a clear MappingException with the offending column name.
Another common pitfall: enum and DateTime properties. SpecFlow.Assist handles string-to-enum conversion automatically if the enum value matches exactly (case-insensitive). But if a table row has a value like 'OutOfStock' and your enum is 'Out_Of_Stock', it throws a ConversionException. The solution is either keep the Gherkin value exactly matching the enum, or use a custom value comparator.
For complex types (nested objects), you can't directly map to child objects from a single table. Instead, store intermediate strings in your context class and parse them in a helper. This keeps Gherkin readable and step definitions clean.
// ── tests/VendingMachine.Specs/Features/ProductCatalogue.feature ───────────── Feature: Product Catalogue Loading As a service technician I want to load product definitions from a spreadsheet So that the vending machine knows what to sell Scenario: Load products from table Given the following product catalogue: | Code | Name | PriceInCents | Category | | A1 | Cola | 150 | Carbonated | | B2 | Water | 100 | Still | | C3 | OrangeJuice| 200 | Juice | When the catalogue is imported Then the machine should have 3 products And the price of product "A1" should be 150 cents // ── tests/VendingMachine.Specs/StepDefinitions/ProductCatalogueSteps.cs ─────── using TechTalk.SpecFlow; using TechTalk.SpecFlow.Assist; using Xunit; namespace io.thecodeforge.Specs.StepDefinitions; using io.thecodeforge.Core; // hypothetical production namespace using io.thecodeforge.Specs.Support; [Binding] public sealed class ProductCatalogueSteps { private readonly ProductCatalogueContext _ctx; public ProductCatalogueSteps(ProductCatalogueContext ctx) { _ctx = ctx; } // ── Enabled strict prototyping so mismatched column names throw immediately ── [Given(@"the following product catalogue:")] public void GivenProductCatalogue(Table table) { // Strict mode: if a column header doesn't match any property, it throws a MappingException. _ctx.Products = table.CreateSet<ProductDefinition>(PrototypingBehavior.Strict).ToList(); } [When(@"the catalogue is imported")] public void WhenCatalogueImported() { // Simulate import logic (not shown for brevity) _ctx.ImportSuccessful = true; } [Then(@"the machine should have {int} products")] public void ThenMachineHasProducts(int count) { Assert.Equal(count, _ctx.Products.Count); } [Then(@"the price of product {string} should be {int} cents")] public void ThenPriceOfProduct(string code, int expectedPrice) { var product = _ctx.Products.Single(p => p.Code == code); Assert.Equal(expectedPrice, product.PriceInCents); } } // ── tests/VendingMachine.Specs/Support/ProductCatalogueContext.cs ───────────── namespace io.thecodeforge.Specs.Support; using io.thecodeforge.Core; public class ProductCatalogueContext { public List<ProductDefinition> Products { get; set; } = new(); public bool ImportSuccessful { get; set; } } // ── src/io.thecodeforge.Core/ProductDefinition.cs ─────────────────────────── namespace io.thecodeforge.Core; public enum ProductCategory { Carbonated, Still, Juice } public record ProductDefinition { public string Code { get; init; } = string.Empty; public string Name { get; init; } = string.Empty; public int PriceInCents { get; init; } public ProductCategory Category { get; init; } }
Who This Actually Matters For (Spoiler: It's Not Everyone)
If you write unit tests and ship code solo, SpecFlow is overhead you don't need. This tool exists for one reason: translation. Business analysts write Gherkin features. Devs wire step definitions. Testers run the suite. The audience is the team. BDD without collaboration is just ceremony.
SpecFlow shines when your stakeholders can read feature files and nod along. If your BA writes English prose and your PM wants living documentation, this is your stack. If you're the only person touching tests, stick to xUnit and skip the abstraction layer.
The prerequisite isn't C# mastery — it's C# fluency. You need to understand dependency injection, lambda expressions, and async patterns. If you panic when you see [Binding] attributes, you're not ready. You also need a working environment: Visual Studio 2022+, .NET 6+, and the SpecFlow extension installed. No excuses.
// io.thecodeforge — csharp tutorial // Run this to verify your environment can compile SpecFlow tests using System; using TechTalk.SpecFlow; [Binding] public class EnvironmentVerificationSteps { [Given(@"the .NET version is (\d+)\.(\d+)")] public void GivenDotNetVersion(int major, int minor) { var actualMajor = Environment.Version.Major; var actualMinor = Environment.Version.Minor; if (actualMajor < major || (actualMajor == major && actualMinor < minor)) throw new Exception($"Need .NET {major}.{minor}+, got {actualMajor}.{actualMinor}"); } }
[ScenarioDependencies] before you write your first [Given].Prerequisites That Actually Matter (Not Just 'Know C#')
C# is table stakes. The real prerequisite is understanding that SpecFlow is a translation layer, not a test framework. You need to know DI, because that's how SpecFlow manages step definition instances. You need to know async, because your Selenium or HttpClient calls will be async. And you need to know the difference between [BeforeScenario] and [BeforeTestRun] — that's where people break their suites.
Tooling: Visual Studio 2022 with SpecFlow extension. ReSharper test runner? It works, but the built-in Test Explorer is faster. You also need NuGet packages: SpecFlow, SpecFlow.NUnit, and SpecFlow.Assist (for data tables). Don't install everything — just what you need.
The audience? Three groups: BAs who write Gherkin, devs who implement steps, and QA who run tests. If your organization doesn't have all three, BDD is a fiction. Be honest about your team before you commit.
// io.thecodeforge — csharp tutorial // Minimum packages for a production SpecFlow project <ItemGroup> <PackageReference Include="SpecFlow" Version="3.9.74" /> <PackageReference Include="SpecFlow.NUnit" Version="3.9.74" /> <PackageReference Include="SpecFlow.Assist" Version="3.9.74" /> <PackageReference Include="NUnit" Version="4.0.1" /> <PackageReference Include="NUnit3TestAdapter" Version="4.5.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" /> </ItemGroup>
dotnet new specflow-project from the SpecFlow CLI template instead of manual NuGet installs. Saves 10 minutes and gets the binding assemblies right first time.Flaky Test Failure Due to Static Shared State in Parallel CI Run
- Static state in step definitions is the primary cause of flaky parallel SpecFlow tests.
- Never use static fields for per-scenario data — always use context injection.
- Run the full suite with parallel execution in CI to expose these issues; single-scenario runs won't catch them.
dotnet list tests/VendingMachine.Specs package | findstr SpecFlowls tests/VendingMachine.Specs/obj/Debug/net8.0/*.feature.csdotnet test --filter 'FullyQualifiedName~Dispensing' --logger 'console;verbosity=detailed' | grep -i ambiguousCheck .feature file steps and compare to step definition attribute stringsgrep -r 'static' tests/VendingMachine.Specs/StepDefinitions/ --include='*.cs'dotnet test --filter 'Category=smoke' -- RunConfiguration.DisableParallelization=true| Aspect | ScenarioContext (Dictionary) | Context Injection (BoDi) |
|---|---|---|
| Thread Safety | Thread-static — breaks in parallel runs | Per-scenario scoped — safe in all parallelism modes |
| Type Safety | Object casting required — runtime errors | Strongly typed — compile-time checked |
| Discoverability | Magic string keys — invisible dependencies | Constructor parameters — dependencies are explicit |
| Testability | Cannot easily unit-test step setup logic | Context class is a POCO — trivially unit-testable |
| Disposal | Manual cleanup in AfterScenario hook required | BoDi calls IDisposable.Dispose() automatically |
| Setup Complexity | Zero config — works out of the box | Needs [BeforeScenario] for non-POCO registrations |
| Best For | Quick prototypes, legacy migrations | Any production SpecFlow suite |
| File | Command / Code | Purpose |
|---|---|---|
| ProjectSetup.sh | dotnet new sln -n VendingMachine | Scaffolding a SpecFlow Project That Won't Embarrass You in C |
| Dispensing.feature | Feature: Vending Machine Dispensing | Gherkin Deep Dive |
| DispensingSteps.cs | namespace VendingMachine.Core; | Step Definitions, Regex Capture, and Shared State with Conte |
| ParallelConfig.cs | /* | Parallel Execution, Tags, and Production-Grade Configuration |
| ProductCatalogueSteps.cs | Feature: Product Catalogue Loading | Data-Driven Testing with SpecFlow.Assist |
| PrerequisitesCheck.cs | using System; | Who This Actually Matters For (Spoiler |
| ProjectSetupCheck.csproj | Prerequisites That Actually Matter (Not Just 'Know C#') |
Key takeaways
Common mistakes to avoid
3 patternsWriting implementation-level Gherkin steps
CalculateChange() with 200 and 150' break on every internal refactor. Non-developers cannot read them, so the specification-executable bridge collapses.Sharing mutable state via static fields in step definition classes
Forgetting to add SpecFlow.Tools.MsBuild.Generation
Interview Questions on This Topic
What is the difference between ScenarioContext and context injection in SpecFlow, and why does it matter in a parallel test run?
How does SpecFlow's MsBuild code generator work, and what happens at runtime when a Gherkin step has no matching step definition?
You have 500 SpecFlow scenarios and the nightly build takes 45 minutes. Walk me through the strategies you'd use to bring it under 10 minutes without removing scenarios.
How do you handle data-driven tests that require different input combinations without duplicating the scenario steps?
Frequently Asked Questions
xUnit and NUnit are test runners — they execute C# test methods and report pass/fail. SpecFlow sits on top of them and adds a Gherkin parsing layer that translates plain-English feature files into those test methods automatically. SpecFlow needs a runner (xUnit, NUnit, or MSTest) underneath it; it doesn't replace them.
Yes, SpecFlow with xUnit supports parallel execution at the feature level by default. It's safe as long as you use context injection instead of static shared state — each scenario gets its own injected context instance. If your scenarios touch an external database, isolate them with per-scenario schemas or transactions that roll back in an AfterScenario hook.
A Scenario is a single concrete test case with fixed values. A Scenario Outline is a template with angle-bracket placeholders and an Examples table — SpecFlow generates one independent test method per row. Use Scenario Outline whenever you want to verify the same behaviour across multiple input/output combinations without duplicating the prose.
Yes, SpecFlow supports all three major .NET test runners: SpecFlow.NUnit, SpecFlow.xUnit, and SpecFlow.MsTest. The choice is mostly a matter of team preference and existing infrastructure. The core features (Gherkin, step definitions, context injection) are identical across runners. Just replace the runner package and adjust the collection/parallelism attributes accordingly.
Use WireMock.Net in a BeforeScenario hook to stub external APIs. Register the mock server's URL as a configuration value in your context. This makes tests deterministic and runs them without network dependency. For scenarios that truly need live API testing, tag them @integration and run them in a separate CI stage. Never let live API calls run in your fast feedback pipeline.
20+ years shipping production .NET services in enterprise systems. Written from production experience, not tutorials.
That's Testing. Mark it forged?
12 min read · try the examples if you haven't