iTestBDD

Scenario Context: Hooks to Step Definitions

Every BDD framework ships some form of shared state container — context in Behave, the ScenarioContext bag in SpecFlow, the world object in Cucumber-JVM and Playwright-BDD. Most teams use them as a dumping ground: set a value in a @Before hook, read it three step definitions later, and hope nothing in between mutates it. That pattern works until it doesn't, and when it breaks, the failure manifests as a flaky test rather than a design error.

The technical problem is straightforward: hooks and step definitions execute in a defined but non-obvious order, and the shared context object is the only sanctioned channel between them. Misunderstand the lifecycle — or let context grow unbounded — and you get state leaking between scenarios, silent assertion skips, and test runs that pass locally but fail under CI parallelism.

By the end of this article you'll be able to model context flow explicitly, choose the right scoping strategy per framework, and avoid the three structural mistakes that cause the most production-grade flakiness in hook-heavy suites.

API Testing using Python, Behave, VS Code & GitHub Copilot

Smarter API Test Automation — Python, Behave, VS Code, AI with GitHub Copilot & CI/CD Pipelines. Complete in a Weekend!

Learn more

The Scenario Context Object and Its Place in the Execution Graph

In Behave, context is a Context instance injected into every hook and step function. It maintains a stack of attribute layers: the test run layer at the bottom, a feature layer above it, and a scenario layer on top. When a scenario ends, Behave pops the scenario layer — any attribute set at that scope is gone. Attributes set in before_all survive the entire run; attributes set in before_scenario live only for that scenario. SpecFlow uses a DI-registered ScenarioContext with an explicit dictionary API; Cucumber-JVM injects world state via PicoContainer or Spring, scoped to the scenario by default.

Understanding where context fits in a modern test architecture matters because hooks are not just setup/teardown — they're the boundary between infrastructure concerns (auth tokens, DB seeds, browser sessions) and domain concerns (the data a step definition needs to assert against). Treating the context object as a typed contract rather than a stringly-typed bag is the difference between a maintainable suite and one where ambient context corrupts step boundaries silently across hundreds of scenarios.

Passing State Through Hooks and Steps Without Leaking It

The cleanest pattern is to initialise every context attribute your scenario will need inside before_scenario, even if the value is None. This makes the contract explicit and prevents AttributeError surprises when a step runs before the hook that was supposed to set the value.

# Behave — environment.py
def before_scenario(context, scenario):
    context.auth_token: str | None = None
    context.created_order_id: int | None = None
    context.api_response = None

def after_scenario(context, scenario):
    if context.auth_token:
        revoke_token(context.auth_token)  # explicit cleanup, not relying on GC

Step definitions then read and write through the same typed attributes. The key discipline: only write to context inside hooks or the step that logically owns the state. A step that asserts should never also set context attributes that a later step reads — that's a hidden dependency chain that makes step reuse impossible.

# steps/auth_steps.py
from behave import given, then

@given('the user has authenticated as "{role}"')
def step_authenticate(context, role):
    context.auth_token = fetch_token(role)   # write once, here

@then('the order confirmation is returned')
def step_check_order(context):
    assert context.api_response.status_code == 201
    context.created_order_id = context.api_response.json()["order_id"]  # OK: this step owns this write

In Cucumber-JVM 7 with PicoContainer, the equivalent is a plain Java class injected into every step class that needs it. PicoContainer creates one instance per scenario automatically — no manual scoping required, which removes an entire class of leakage bugs.

// ScenarioState.java — shared across step classes via constructor injection
public class ScenarioState {
    public String authToken;
    public int createdOrderId;
    public Response apiResponse;
}

// OrderSteps.java
public class OrderSteps {
    private final ScenarioState state;
    public OrderSteps(ScenarioState state) { this.state = state; }

    @Then("the order confirmation is returned")
    public void orderConfirmed() {
        assertEquals(201, state.apiResponse.statusCode());
        state.createdOrderId = state.apiResponse.jsonPath().getInt("order_id");
    }
}

In SpecFlow with .NET dependency injection, register your context class as ScenarioInstanceContext (scoped lifetime) and inject it via the constructor. Run time improvements from this pattern are real: one internal migration from a static helper bag to typed injection reduced intermittent failures in a 400-scenario suite from 11 per run to zero, because the static helper had been sharing state across parallel threads. If you're building a scalable BDD framework, typed injection at the scenario scope is non-negotiable once you hit parallel execution.

Three Hook-Layer Mistakes That Survive Code Review

The first is writing to context in an after_step hook and reading it in a subsequent step. The hook fires after the step completes, so the write is available to the next step — but only if the scenario doesn't fail first. When a step fails, after_step still fires, but downstream steps are skipped, leaving context in a partially-written state that poisons the after_scenario cleanup. The fix: move any state that cleanup depends on into the step itself, not the post-step hook. The second is using feature-scoped context for data that should be scenario-scoped. In Behave, anything set in before_feature persists across all scenarios in that feature file. Teams do this to avoid re-authenticating for every scenario — a reasonable optimisation — but then a scenario mutates that token and every subsequent scenario in the file fails in a non-obvious way.

The third mistake is tagging hooks with broad @wip or @slow filters and forgetting that untagged scenarios still run the base hooks. A hook registered without a tag guard runs for every scenario. When that hook does expensive work — spinning up a Docker container, seeding a database — you pay the cost even for scenarios that don't need it. Use context.tags guards explicitly, and audit hook execution time with Behave's --dry-run plus timing output. This is also where CI parallelism causes hooks to fire twice — once per agent — when the hook registration isn't agent-aware.

What Teams Get Wrong About Step Scenario Boundaries

The most persistent myth is that Scenario Outlines share no state between table rows. They don't share context — each row is a separate scenario execution — but they do share step definition bindings. If a step definition has a side effect that writes to a module-level variable (a common shortcut in Python), every row in the outline will read stale state from the previous row's execution. The step outline pattern looks clean in Gherkin but silently multiplies the surface area for this bug. The same brittleness applies when you're generating steps with AI tooling: Scenario Outline tables can silently multiply brittle step bindings across a test suite without any single step definition looking wrong in isolation.

The second misunderstanding is treating the world object as a logging sink. Engineers append diagnostic data to context throughout a scenario so they can inspect it on failure. That's reasonable, but it means the context object grows with every step and carries stale intermediate values. When a step definition later checks hasattr(context, 'response') instead of asserting a specific attribute, it passes even when the response was set by a completely different scenario path. Explicit initialisation in before_scenario — as shown above — closes this gap. If you're using LLM-generated step definitions, this problem compounds: the model may set context attributes that your hand-written steps never expect, which is a specific failure mode worth reading about in the context of context-driven testing with LLMs.

The context object is a contract, not a convenience. Initialise every attribute at before_scenario, scope state to the step that owns it, and audit hook tag guards before you scale to parallel execution. The next thing worth measuring once you've tightened context discipline is mean-time-to-detect on flaky tests — a well-scoped context makes flakiness deterministic rather than intermittent, which makes root cause analysis tractable instead of a guessing game.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles