Homeβ€Ί C# / .NETβ€Ί BDD with SpecFlow in C#: Gherkin, Step Definitions & Real-World Pitfalls

BDD with SpecFlow in C#: Gherkin, Step Definitions & Real-World Pitfalls

In Plain English πŸ”₯
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.
⚑ Quick Answer
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.

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.sh Β· CSHARP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950
# ── 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 GeneratorIf 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.

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.feature Β· CSHARP
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
# 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 (200,B2,Water,100) [PASS]
# Change calculation across multiple price points (300,B2,Water,200) [PASS]
⚠️
Pro Tip: Scenario Outline Row Names in CIIn 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.

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.cs Β· CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
// ── 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 DefinitionsIf 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.

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.cs Β· CSHARP
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
// ── 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.CurrentScenarioContext.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.
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.

⚠ Common Mistakes to Avoid

  • βœ•Mistake 1: Writing implementation-level Gherkin steps β€” 'When I call CalculateChange() with 200 and 150' β€” makes scenarios brittle to refactors and meaningless to non-developers. Fix: describe observable, user-visible 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.
  • βœ•Mistake 2: Sharing mutable state via static fields in step definition classes β€” in parallel test runs two scenarios overwrite each other's data causing random, unreproducible failures. Fix: move all per-scenario state into a context class and inject it via constructor. SpecFlow's BoDi container creates a fresh instance per scenario and disposes it after β€” zero manual cleanup needed.
  • βœ•Mistake 3: Forgetting to add SpecFlow.Tools.MsBuild.Generation β€” feature files exist on disk but generate zero test methods, and dotnet test reports 'No tests found' with no diagnostic error. 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?
  • QHow does SpecFlow's MsBuild code generator work, and what happens at runtime when a Gherkin step has no matching step definition?
  • 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.

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.

πŸ”₯
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful β€” not just SEO filler.

← PreviousCaching in ASP.NET CoreNext β†’Rate Limiting in ASP.NET Core
Forged with πŸ”₯ at TheCodeForge.io β€” Where Developers Are Forged