Advanced 6 min · March 06, 2026

SpecFlow Static HttpClient — CI Race Condition Fix

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

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
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

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.

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.

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.

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

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

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

Common Mistakes to Avoid

  • 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 Questions on This Topic

  • QWhat is the difference between ScenarioContext and context injection in SpecFlow, and why does it matter in a parallel test run?SeniorReveal
    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.
  • QHow does SpecFlow's MsBuild code generator work, and what happens at runtime when a Gherkin step has no matching step definition?Mid-levelReveal
    The MsBuild generator reads each .feature file during compilation and emits a .feature.cs partial class containing one xUnit/NUnit test method per scenario. These methods call the SpecFlow runtime engine which matches Gherkin steps to registered step definitions using regex or SpecFlow Expressions. If no match is found, the test fails with a BindingException at runtime, unless missingOrPendingStepsOutcome is set to Pending (which produces a 'Pending' result rather than a failure).
  • QYou have 500 SpecFlow scenarios and the nightly build takes 45 minutes. Walk me through the strategies you'd use to bring it under 10 minutes without removing scenarios.SeniorReveal
    First, enable parallel execution at the feature level (xUnit default) and ensure context injection is used everywhere to avoid static state collisions. Second, split the suite into tiers using tags: @smoke (fast, <10s), @integration (network-dependent, ~2min), @slow (performance, ~5min). Run @smoke on every PR, @integration hourly, and @slow nightly. Third, use shared context (e.g., in-memory database initialized once per feature via BeforeFeature) to reduce redundant setup. Fourth, consider running multiple builds in parallel using test splitting tools like NUnit's --workers or xUnit's built-in parallelism. Finally, remove any hard waits (Thread.Sleep) and replace with retry policies or polling with timeouts.
  • QHow do you handle data-driven tests that require different input combinations without duplicating the scenario steps?JuniorReveal
    Use Scenario Outline with an Examples table. The outline defines the steps once with placeholders in angle brackets, and the Examples section provides rows of data. Each row generates a separate test method named with the parameter values. This keeps the Gherkin clean and makes failures pinpoint the exact input combination. For dynamic data not suitable for a static table, pass the data as a single string and parse it in a step definition, or use context injection to pre-load data from a file.

Frequently Asked Questions

What is the difference between SpecFlow and NUnit or xUnit?

xUnit and NUnit are test runners — they execute C# test methods and report pass/fail. SpecFlow sits on top of them and adds a Gherkin parsing layer that translates plain-English feature files into those test methods automatically. SpecFlow needs a runner (xUnit, NUnit, or MSTest) underneath it; it doesn't replace them.

Can SpecFlow scenarios run in parallel, and is it safe?

Yes, SpecFlow with xUnit supports parallel execution at the feature level by default. It's safe as long as you use context injection instead of static shared state — each scenario gets its own injected context instance. If your scenarios touch an external database, isolate them with per-scenario schemas or transactions that roll back in an AfterScenario hook.

What is the difference between a Scenario and a Scenario Outline in SpecFlow?

A Scenario is a single concrete test case with fixed values. A Scenario Outline is a template with angle-bracket placeholders and an Examples table — SpecFlow generates one independent test method per row. Use Scenario Outline whenever you want to verify the same behaviour across multiple input/output combinations without duplicating the prose.

Can I use SpecFlow with NUnit instead of xUnit?

Yes, SpecFlow supports all three major .NET test runners: SpecFlow.NUnit, SpecFlow.xUnit, and SpecFlow.MsTest. The choice is mostly a matter of team preference and existing infrastructure. The core features (Gherkin, step definitions, context injection) are identical across runners. Just replace the runner package and adjust the collection/parallelism attributes accordingly.

What is the best way to handle external API calls in SpecFlow tests?

Use WireMock.Net in a BeforeScenario hook to stub external APIs. Register the mock server's URL as a configuration value in your context. This makes tests deterministic and runs them without network dependency. For scenarios that truly need live API testing, tag them @integration and run them in a separate CI stage. Never let live API calls run in your fast feedback pipeline.

🔥

That's Testing. Mark it forged?

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

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