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
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.
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.
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.
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.
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
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
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.
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.
[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.
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.SpecFlow 4.x Project Structure and LivingDoc
SpecFlow 4.x introduced a recommended project structure that separates feature files, step definitions, and support files into dedicated folders. This structure improves maintainability and aligns with the LivingDoc feature, which generates human-readable documentation from your Gherkin scenarios. LivingDoc can be integrated into CI/CD pipelines to provide living documentation that stays in sync with your tests.
To set up a SpecFlow 4.x project: 1. Create a folder named 'Features' for all .feature files. 2. Create a folder named 'StepDefinitions' for step definition classes. 3. Create a folder named 'Support' for hooks, context classes, and configuration. 4. Add a 'specflow.json' configuration file in the project root.
Example specflow.json: ``json { "livingDocGenerator": { "enabled": true, "filePath": "TestResults\\LivingDoc.html" }, "stepAssemblies": [ { "assembly": "YourProject.StepDefinitions" } ] } ``
LivingDoc generates an HTML report that includes scenario descriptions, tags, and execution results. It can be published as part of your CI pipeline, ensuring stakeholders always have access to the latest test documentation.
ScenarioContext and Dependency Injection in SpecFlow Hooks
SpecFlow hooks (BeforeScenario, AfterScenario, etc.) often need access to shared state like test data or API clients. Using ScenarioContext and dependency injection (DI) provides a clean way to manage this without static state.
ScenarioContext is a dictionary-like container that persists across hooks and steps within a scenario. It can store objects that need to be shared, such as an HTTP response or a database record. However, relying on ScenarioContext for complex dependencies can lead to messy code. Instead, use SpecFlow's built-in DI container (BoDi) to inject dependencies into hooks.
Example: Inject an HttpClient into a hook via constructor injection: ```csharp [Binding] public class ApiHooks { private readonly HttpClient _httpClient;
public ApiHooks(HttpClient httpClient) { _httpClient = httpClient; }
[BeforeScenario] public void BeforeScenario() { // Use _httpClient to set up test data } } ```
To register HttpClient in the DI container, create a 'SpecFlowDependencyInjection' class with a static method: ``csharp [ScenarioDependencies] public static Services ``CreateServices() { var services = new ServiceCollection(); services.AddHttpClient(); return services.BuildServiceProvider(); }
This approach avoids static state and makes hooks testable and maintainable.
SpecFlow vs xUnit BDD Style: Which Approach for .NET in 2026
By 2026, both SpecFlow and xUnit's BDD-style testing (using Fact and Theory attributes with fluent assertions) remain popular. The choice depends on team preferences and project requirements.
SpecFlow provides Gherkin feature files that are readable by non-technical stakeholders. It excels in scenarios where business analysts or product owners write or review tests. SpecFlow integrates with LivingDoc for documentation and supports parallel execution via NUnit or xUnit runners. However, it adds complexity with step definitions and binding.
xUnit BDD Style uses plain C# with descriptive test method names and fluent assertions (e.g., FluentAssertions). It is simpler to set up and maintain, as there is no separate Gherkin layer. Teams that are developer-heavy often prefer this approach because it stays within the codebase and avoids the overhead of feature files.
Example xUnit BDD-style test: ``csharp public class CalculatorTests { [Fact] public void ``Adding_two_numbers_returns_sum() { var calculator = new Calculator(); var result = calculator.Add(2, 3); result.Should().Be(5); } }
In 2026, the trend is toward hybrid approaches: use SpecFlow for high-level acceptance tests that require stakeholder collaboration, and xUnit BDD style for lower-level integration or unit tests. Both can coexist in the same project.
Decision factors: - If non-developers write tests → SpecFlow. - If team is all developers and wants simplicity → xUnit BDD. - If living documentation is required → SpecFlow with LivingDoc. - If CI speed is critical → xUnit BDD (less overhead).
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.cs| 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#') | |
| specflow.json | { | SpecFlow 4.x Project Structure and LivingDoc |
| ApiHooks.cs | [Binding] | ScenarioContext and Dependency Injection in SpecFlow Hooks |
| CalculatorTests.cs | public class CalculatorTests | SpecFlow vs xUnit BDD Style |
Key takeaways
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?
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Written from production experience, not tutorials.
That's Testing. Mark it forged?
14 min read · try the examples if you haven't