Junior 15 min · March 06, 2026
BDD with SpecFlow in C#

SpecFlow Static HttpClient — CI Race Condition Fix

Static HttpClient in SpecFlow causes random CI failures when parallel scenarios mutate BaseAddress.

N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Written from production experience, not tutorials.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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
✦ Definition~90s read
What is BDD with SpecFlow in C#?

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].

Imagine you're building a vending machine with your team.

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.

Plain-English First

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.

Static State Is a Trap
SpecFlow scenarios run in parallel by default; a static HttpClient shared across scenarios will corrupt state and produce flaky failures that only reproduce under CI load.
Production Insight
A trading platform's CI suite passed locally but failed randomly in CI: 3% of order-submission tests threw 'ObjectDisposedException' on HttpClient. Symptom: concurrent scenarios reused a static HttpClient whose internal message handler was disposed by another scenario's cleanup. Rule: never share static HttpClient across SpecFlow scenarios—use a fresh instance per scenario or inject via a scoped factory.
Key Takeaway
SpecFlow bridges business language and C# tests via Gherkin step bindings.
Each scenario runs in its own DI scope—static state breaks isolation.
Parallel CI execution exposes shared-resource bugs that single-threaded runs hide.
SpecFlow Static HttpClient Race Condition Fix THECODEFORGE.IO SpecFlow Static HttpClient Race Condition Fix CI pipeline fix for shared HttpClient in SpecFlow tests Feature File Parsing Gherkin scenarios read by SpecFlow Step Definition Binding Regex capture maps to C# methods Static HttpClient Shared Single instance across scenarios Race Condition in CI Concurrent tests corrupt state HttpClientFactory Injection Per-scenario instance via DI Stable Test Execution No shared state, CI passes ⚠ Static HttpClient causes race conditions in parallel tests Use IHttpClientFactory per scenario to avoid shared state THECODEFORGE.IO
thecodeforge.io
SpecFlow Static HttpClient Race Condition Fix
Bdd Specflow Csharp

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:

  1. Feature File – Written in Gherkin syntax (.feature file). Contains Feature, Background, Scenario, Scenario Outline, Examples.
  2. 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.
  3. 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.
  4. 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.
  5. Hooks[BeforeScenario] and [AfterScenario] run before/after each scenario, outside step definitions. Feature-level hooks also exist.
  6. Test Runner – xUnit/NUnit takes over, runs the generated test method, which internally calls step definitions. Assertions inside step definitions determine pass/fail.
Production Insight
When troubleshooting a 'no tests found' issue, the most common break point is step 2 — the MsBuild generator didn't run. Always check for .feature.cs in the obj/ folder. If it exists but tests still don't run, the generated class may have missing attributes due to a .csproj configuration error.
Key Takeaway
The BDD lifecycle is a chain from Gherkin → generated test method → step definitions → assertions. The MsBuild generator is the crucial link that ties feature files to the test runner.
SpecFlow BDD Lifecycle
PassFailfeature FileMsBuild Generatorfeature.cs generated partialclassTest Runner xUnit or NUnitStep Definition ResolutionBoDi Context InjectionExecute Step DefinitionsAssertionsScenario PassScenario FailBeforeScenario hookAfterScenario hook

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.

ProjectSetup.shCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# ── 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
Output
Build succeeded.
VendingMachine.Core -> src/VendingMachine.Core/bin/Debug/net8.0/VendingMachine.Core.dll
VendingMachine.Specs -> tests/VendingMachine.Specs/bin/Debug/net8.0/VendingMachine.Specs.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Watch Out: Missing MsBuild Generator
If you add SpecFlow and SpecFlow.xUnit but forget SpecFlow.Tools.MsBuild.Generation, your .feature files exist on disk but produce zero test methods. The test runner will show 'No tests found' with no error. Always verify the .feature.cs files are generated by checking the obj/ folder after a build.
Production Insight
Most teams discover missing MsBuild generator only when CI shows 0 tests after hours of debugging.
A quick build check: if obj/Debug/net8.0/*.feature.cs doesn't exist, you're missing the package.
Rule: add SpecFlow.Tools.MsBuild.Generation to every new SpecFlow project on day one.
Key Takeaway
Install all three SpecFlow packages together.
Missing the MsBuild generator = zero tests with zero error.
Verify .feature.cs existence after build — it's the only reliable signal.

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.

Dispensing.featureCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 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            |
Output
# No direct console output — this is a feature file.
# After dotnet build, SpecFlow generates Dispensing.feature.cs in the obj/ folder.
# dotnet test will discover and list:
#
# VendingMachine.Specs
# Dispensing - Vending Machine Dispensing
# Customer receives product and exact change [PASS]
# Customer selects an out-of-stock product [PASS]
# Customer inserts too little money [PASS]
# Change calculation across multiple price points (150,A1,Cola,0) [PASS]
# Change calculation across multiple price points (200,A1,Cola,50) [PASS]
# Change calculation across multiple price points (300,B2,Water,100) [PASS]
# Change calculation across multiple price points (300,B2,Water,200) [PASS]
Pro Tip: Scenario Outline Row Names in CI
In CI dashboards, Scenario Outline test names include the parameter values from Examples rows. Keep parameter values short and descriptive — a 200-character parameter value creates unreadable test names in Azure DevOps and GitHub Actions. If your Examples table has complex JSON, store the complex value in a named constant and reference the name instead.
Production Insight
Background over-use is the fastest path to unreadable scenarios.
When you add 'Given the user is logged in' as Background, every scenario hides that setup.
Rule: if a scenario can't be understood in isolation, Background has too much.
Key Takeaway
Background for truly universal setup only.
Scenario Outline for data-driven behaviour — one row per test case.
Good Gherkin reads like a specification, not like a method invocation.

Gherkin Syntax Cheat Sheet

This table summarises all Gherkin keywords and their usage in SpecFlow. Keep it handy when writing feature files:

KeywordPurposeExample / Notes
FeatureHigh-level description of a featureFeature: Vending Machine Dispensing — Should include a business-readable description typically on the next line.
BackgroundSteps that run before every scenario in the fileUse for shared setup like loading product data. Avoid overuse.
ScenarioA single concrete test caseScenario: Customer receives product and exact change
GivenPreconditions / state setupGiven the vending machine is stocked — Executed first.
WhenAction the user performsWhen the customer selects product "A1" — The trigger.
ThenExpected outcomeThen the customer receives "Cola" — Assertions go here.
And / ButAdditional steps of the same type (Given/When/Then)And the customer receives 50 cents change — Inherits the keyword of the preceding step.
Scenario OutlineData-driven template with placeholdersScenario Outline: Change calculation paired with Examples: table.
ExamplesData table for Scenario OutlineEach row generates a separate test. Headers become placeholders.
#Comment (ignored)# This is a comment — Use sparingly; scenarios should be self-explanatory.
@tagTag 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.

Production Insight
Teams new to BDD often misuse 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.
Key Takeaway
Gherkin has only six keywords. Use 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

CriteriaScenario Outline + ExamplesInline Data Table (SpecFlow.Assist)
Test generationEach Examples row → separate test methodSingle test method, data used within step
ReportingFailures pinpoint exact rowFailure at scenario level; must add context to know which row failed
Step definition complexitySingle template step with placeholders; parameters passed automaticallyNeed to write CreateSet<T>() and sometimes manual iteration
Readability for non-devsVery high — entire scenario readableLess intuitive; the table is just one step's input
Best forTesting multiple input/output combinations that should all passSupplying a collection of objects for setup (e.g., initial stock)
ExampleTesting change for different coin amountsLoading product catalogue from a spreadsheet
PerformanceMore test methods, but each is fastOne 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.

Production Insight
I've seen teams use Scenario Outline for everything, including setup tables. This creates dozens of nearly identical test methods for what should be a single setup step. Keep Scenario Outline for behaviour that truly varies per row. For static setup data, an inline table is cleaner and faster.
Key Takeaway
Scenario Outline + Examples for data-driven test cases (each row = a test). Inline Data Tables for passing structured data to a single step. Choose based on whether the data defines test cases or is just input to setup.

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.

DispensingSteps.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
// ── 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.
    }
}
Output
dotnet test tests/VendingMachine.Specs --logger "console;verbosity=normal"
[START] Customer receives product and exact change
[PASSED] Customer receives product and exact change
[START] Customer selects an out-of-stock product
[PASSED] Customer selects an out-of-stock product
[START] Customer inserts too little money
[PASSED] Customer inserts too little money
[START] Change calculation across multiple price points
[PASSED] Change calculation across multiple price points
[START] Change calculation across multiple price points
[PASSED] Change calculation across multiple price points
[START] Change calculation across multiple price points
[PASSED] Change calculation across multiple price points
[START] Change calculation across multiple price points
[PASSED] Change calculation across multiple price points
Passed! - Failed: 0, Passed: 7, Skipped: 0, Total: 7
Watch Out: Ambiguous Step Definitions
If two step definition methods match the same Gherkin text, SpecFlow throws AmbiguousStepDefinitionException at runtime — not at build time. You won't catch this until a test runs. Prefix patterns carefully, and run 'dotnet test' as part of your PR pipeline so ambiguity errors surface before merge. SpecFlow.Assist's Table helpers also throw if a column header doesn't match any property name — enable the strict mode option to get a clear error message instead of silent nulls.
Production Insight
Ambiguous step definitions are silent at build time but explode at test runtime.
In a large suite with hundreds of steps, the first to fail is rarely the ambiguous one — you debug the wrong scenario.
Rule: name your step methods with enough context (e.g., 'GivenCustomerInsertsMoney') to prevent accidental overlap.
Key Takeaway
Context injection (constructor) over ScenarioContext.Current always for parallel safety.
SpecFlow Expressions ({int}, {string}) are preferred over raw regex.
Ambiguous step definitions are runtime-only — prevent with careful naming and PR checks.

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 AttributeScopeWhen It RunsUse Case
[BeforeTestRun]Once per test runBefore any feature file is executedStart a global container, set up shared mock servers, initialise logging. Runs once only. Must be static.
[AfterTestRun]Once per test runAfter all features have completedClean up global resources. Runs once only. Must be static.
[BeforeFeature]Once per feature fileBefore any scenario in the feature runsFeature-level setup (e.g., creating a database schema for the feature). Can be static or instance.
[AfterFeature]Once per feature fileAfter all scenarios in the feature have runClean up feature-level resources.
[BeforeScenario]Once per scenarioBefore the first Given step of the scenarioPer-scenario setup: create a clean context, reset state, start transaction. Most commonly used.
[AfterScenario]Once per scenarioAfter the last Then step, even if scenario failsPer-scenario cleanup: rollback transaction, log results, capture screenshot on failure.
[BeforeScenarioBlock]Per step block (Given/When/Then)Before the first step of each blockRarely used; can be used to log timing of phases.
[AfterScenarioBlock]Per step blockAfter the last step of the blockRarely used.
[BeforeStep]Per individual stepBefore each step definition methodVery granular; use for step-level logging or retries.
[AfterStep]Per individual stepAfter each step definition methodStep-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.

Production Insight
A common mistake is putting database cleanup in [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.
Key Takeaway
Hooks execute in a well-defined order: global → feature → scenario → step. Use [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.

ParallelConfig.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
// ── 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 { /* ... */ }
Output
# dotnet test --filter 'Category=smoke'
Test run for tests/VendingMachine.Specs/bin/Debug/net8.0/VendingMachine.Specs.dll (.NETCoreApp,Version=v8.0)
Microsoft (R) Test Execution Command Line Tool Version 17.8.0
Smoke setup for: Customer receives product and exact change
[START] Customer receives product and exact change
[PASSED] Customer receives product and exact change
Passed! - Failed: 0, Passed: 1, Skipped: 6, Total: 7
# Only @smoke-tagged scenario ran. The other 6 were skipped.
Interview Gold: Why Context Injection Over ScenarioContext.Current
ScenarioContext.Current is a thread-static singleton — it works in sequential runs but silently returns the wrong context in parallel runs. Context injection via constructor is thread-safe because BoDi creates and scopes the object per-scenario per-thread. Always prefer constructor injection. This exact distinction comes up in senior BDD interviews.
Production Insight
Parallelism in xUnit + SpecFlow finds every static state bug.
When a scenario fails in CI but passes locally, the culprit is usually a static field or a shared HttpClient.
Rule: set DisableTestParallelization=true to isolate the failure, then eliminate static state.
Key Takeaway
Use context injection for thread-safe per-scenario state.
Tags + filters let you run targeted subsets in CI.
Set missingOrPendingStepsOutcome: Error to fail-fast on unbound steps.
Never use ScenarioContext.Current in parallel — it's thread-static and corrupts data.

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.

ProductCatalogueSteps.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// ── 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; }
}
Output
dotnet test --filter 'FullyQualifiedName~ProductCatalogue'
[START] Load products from table
[PASSED] Load products from table
Passed! - Failed: 0, Passed: 1, Skipped: 0, Total: 1
Strict Mode for Table Mapping
Always call CreateSet<T>(PrototypingBehavior.Strict) in production test suites. Without strict mode, a renamed property column (e.g., 'PriceCents' instead of 'PriceInCents') results in a property silently left at default(0). Strict mode throws a MappingException with the column name — you fix it before the PR merges.
Production Insight
Silent null mapping is the most common data-driven test bug in SpecFlow.
We lost two days debugging a pricing scenario that always passed because the column 'Discount' didn't map to any property — the discount was always zero.
Rule: always use strict prototyping in CI tests, and only revert to lax mode during exploratory development.
Key Takeaway
CreateSet<T>() / CreateInstance<T>() are powerful but silent on mapping failures.
Enable PrototypingBehavior.Strict to catch column mismatches immediately.
For complex data (enums, nested objects), pre-process in step definitions or context helpers.
When to Use CreateSet vs CreateInstance vs Manual Parsing
IfTable has multiple rows, all columns map directly to one object type
UseUse CreateSet<T>() with strict prototyping
IfTable has exactly one data row, maps to a single object
UseUse CreateInstance<T>() with strict prototyping
IfTable contains nested data or non-trivial transformations
UseUse manual row iteration with explicit parsing in a helper method

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.

PrerequisitesCheck.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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}");
    }
}
Output
// No output — test passes if you're on .NET 6 or higher. Red bar means upgrade.
Production Trap:
Do NOT attempt SpecFlow without understanding context injection. I've seen teams waste a week debugging static state in step definitions. Learn [ScenarioDependencies] before you write your first [Given].
Key Takeaway
BDD only pays off when non-devs read and write feature files. Otherwise, just write unit tests.

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.

ProjectSetupCheck.csprojCSHARP
1
2
3
4
5
6
7
8
9
10
11
// 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>
Output
// Run 'dotnet restore' then 'dotnet test' — should discover zero tests because you have no feature files yet. That's correct.
Senior Shortcut:
Use 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.
Key Takeaway
You need three personas on your team: writer, implementer, runner. Missing any one? Don't start SpecFlow.
● Production incidentPOST-MORTEMseverity: high

Flaky Test Failure Due to Static Shared State in Parallel CI Run

Symptom
Random test failures in CI that cannot be reproduced locally when running a single scenario. The failure always occurs in a scenario that relies on a static HttpClient that was mutated by a previous scenario.
Assumption
The static HttpClient was thought to be safe because it's read-only after initialisation.
Root cause
The HttpClient's BaseAddress was set per-scenario in a static field, causing race conditions in parallel runs — one scenario's request went to the wrong endpoint.
Fix
Inject HttpClient via IHttpClientFactory and register it per-scenario via BoDi context injection. Remove all static fields from step definition classes.
Key lesson
  • 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.
Production debug guideSymptom → Immediate Action to Diagnose and Resolve3 entries
Symptom · 01
Test runner reports 'No tests found' for .feature files
Fix
Verify that SpecFlow.Tools.MsBuild.Generation NuGet package is installed. Check that .feature files have Build Action set to None (not Compile) in .csproj. Rebuild and inspect obj/ folder for generated .feature.cs files.
Symptom · 02
AmbiguousStepDefinitionException thrown at test runtime
Fix
Check all [Given], [When], [Then] methods for duplicate patterns. Prefix Gherkin steps with unique context (e.g., 'the customer', 'the administrator'). Use dotnet test --filter 'FullyQualifiedName~scenarioName' to isolate and identify the ambiguous matches.
Symptom · 03
Scenario fails in CI but passes locally
Fix
Check for static state shared across scenarios. Replace any static fields in step definition classes with per-scenario context injection. Ensure DbContext and HttpClient are created per-scenario using BoDi container. Run full suite with xUnit's CollectionBehavior.PerCollection to force sequential order and see if failures go away.
★ Quick Debug Cheat Sheet for SpecFlowCommon SpecFlow failures and the exact commands to diagnose them.
No tests discovered for .feature files
Immediate action
Check if SpecFlow.Tools.MsBuild.Generation is installed
Commands
dotnet list tests/VendingMachine.Specs package | findstr SpecFlow
ls tests/VendingMachine.Specs/obj/Debug/net8.0/*.feature.cs
Fix now
Add the missing package: dotnet add package SpecFlow.Tools.MsBuild.Generation
AmbiguousStepDefinitionException+
Immediate action
List all step definitions and look for duplicate patterns
Commands
dotnet test --filter 'FullyQualifiedName~Dispensing' --logger 'console;verbosity=detailed' | grep -i ambiguous
Check .feature file steps and compare to step definition attribute strings
Fix now
Add more specific phrasing to one of the ambiguous step patterns (e.g., include scenario context)
Parallel test failures due to shared mutable state+
Immediate action
Check for static fields in step definition classes
Commands
grep -r 'static' tests/VendingMachine.Specs/StepDefinitions/ --include='*.cs'
dotnet test --filter 'Category=smoke' -- RunConfiguration.DisableParallelization=true
Fix now
Move shared state into a context class and inject via constructor; remove all static mutable fields
AspectScenarioContext (Dictionary)Context Injection (BoDi)
Thread SafetyThread-static — breaks in parallel runsPer-scenario scoped — safe in all parallelism modes
Type SafetyObject casting required — runtime errorsStrongly typed — compile-time checked
DiscoverabilityMagic string keys — invisible dependenciesConstructor parameters — dependencies are explicit
TestabilityCannot easily unit-test step setup logicContext class is a POCO — trivially unit-testable
DisposalManual cleanup in AfterScenario hook requiredBoDi calls IDisposable.Dispose() automatically
Setup ComplexityZero config — works out of the boxNeeds [BeforeScenario] for non-POCO registrations
Best ForQuick prototypes, legacy migrationsAny production SpecFlow suite

Key takeaways

1
Always add SpecFlow.Tools.MsBuild.Generation
without it your feature files produce zero tests and the runner gives you no error, just silence.
2
Use context injection via BoDi constructor injection for all shared scenario state
ScenarioContext.Current is thread-static and silently breaks in parallel runs.
3
Gherkin scenarios should describe observable user behaviour, not method calls
a scenario that survives an internal refactor is worth ten that don't.
4
missingOrPendingStepsOutcome
Error in specflow.json is non-negotiable in CI pipelines — it converts a silent pending step into a build-breaking failure.
5
Enable PrototypingBehavior.Strict for SpecFlow.Assist table mapping to catch column naming mismatches immediately.

Common mistakes to avoid

3 patterns
×

Writing implementation-level Gherkin steps

Symptom
Scenarios like 'When I call CalculateChange() with 200 and 150' break on every internal refactor. Non-developers cannot read them, so the specification-executable bridge collapses.
Fix
Describe observable user behaviour: 'When I insert $2.00 and select a $1.50 cola'. The test survives a complete internal redesign and remains readable by a product manager.
×

Sharing mutable state via static fields in step definition classes

Symptom
Random flaky failures in parallel CI runs because two scenarios overwrite each other's data. The failure is non-reproducible when running scenarios individually.
Fix
Move all per-scenario state into a context class (e.g., VendingMachineContext) and inject it via constructor. BoDi creates a fresh instance per scenario and disposes it after — zero manual cleanup needed.
×

Forgetting to add SpecFlow.Tools.MsBuild.Generation

Symptom
Feature files exist on disk but dotnet test reports 'No tests found' with no diagnostic error. Hours wasted checking test runner setup.
Fix
Add the NuGet package, rebuild, then verify .feature.cs files appear inside the obj/ folder. If they don't appear after a clean rebuild, check that the feature file's Build Action is set to None (not Compile) in the .csproj.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between ScenarioContext and context injection in ...
Q02SENIOR
How does SpecFlow's MsBuild code generator work, and what happens at run...
Q03SENIOR
You have 500 SpecFlow scenarios and the nightly build takes 45 minutes. ...
Q04JUNIOR
How do you handle data-driven tests that require different input combina...
Q01 of 04SENIOR

What is the difference between ScenarioContext and context injection in SpecFlow, and why does it matter in a parallel test run?

ANSWER
ScenarioContext.Current is a thread-static singleton — it works in sequential runs but silently returns the wrong context in parallel runs because each thread may see another scenario's data. Context injection via constructor uses BoDi's per-scope container, which creates a fresh instance per scenario per thread. Always prefer context injection for thread safety and explicit dependencies.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between SpecFlow and NUnit or xUnit?
02
Can SpecFlow scenarios run in parallel, and is it safe?
03
What is the difference between a Scenario and a Scenario Outline in SpecFlow?
04
Can I use SpecFlow with NUnit instead of xUnit?
05
What is the best way to handle external API calls in SpecFlow tests?
N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Written from production experience, not tutorials.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's Testing. Mark it forged?

15 min read · try the examples if you haven't

Previous
Integration Testing in ASP.NET Core
4 / 5 · Testing
Next
Contract Testing in .NET