iTestBDD

Cucumber World Object State Leaks Across Scenarios

Cucumber-JVM 7, Behave, and SpecFlow all share the same conceptual contract: each scenario is an isolated unit of behavior. In practice, that contract breaks the moment a team starts treating the World object — or its equivalent — as a convenient bag for cross-step data. The test suite still goes green locally. It falls apart in CI, usually only under parallel execution, and usually only for the scenarios that run second.

The mechanics are subtle. Cucumber's World (Ruby/JavaScript), Behave's context object, and SpecFlow's ScenarioContext are all designed to be scenario-scoped. The trap is that engineers attach objects to them that are not scenario-scoped: database connections, browser sessions, HTTP clients, or domain model instances that were initialized once and never torn down cleanly. The World object itself resets; the thing it points to does not.

By the end of this article you'll be able to identify the three most common World-leak patterns in Cucumber-JVM and Behave suites, reproduce them in a minimal fixture, and apply the correct teardown and injection strategy to eliminate them — without restructuring your entire step library.

IBYOK

Stop juggling LLM API keys across apps and environments. IBYOK securely manages keys for 60+ AI providers in one encrypted vault—start free today.

Learn more

What the World Object Actually Owns (and What It Doesn't)

In Cucumber-JS and Ruby Cucumber, World is the this context injected into every step definition for a given scenario. Cucumber-JVM replaces it with dependency-injected step classes (via PicoContainer, Spring, or Guice); Behave uses a context object threaded through hooks and steps. SpecFlow uses a combination of ScenarioContext and constructor injection. The abstraction differs, but the intent is identical: one fresh object graph per scenario, discarded after the scenario's After hooks complete.

The critical distinction is ownership vs. reference. The World object is freshly instantiated each scenario. But if a step class holds a reference to a singleton — a Spring-managed WebDriver bean, a Behave fixture created in an environment.py before_all block, or a static field on a JVM step class — the World resets while the underlying resource does not. This is the leak. It doesn't show up in isolation because sequential scenarios happen to leave the resource in a usable state. It surfaces under parallel BDD runs, where two scenarios race on the same undisposed resource simultaneously.

Reproducing and Fixing the Three Common Leak Patterns

The fastest way to confirm a World leak is to run your suite with --order random (Behave) or randomized scenario ordering in Cucumber-JVM and watch for order-dependent failures. If the same scenario fails only when preceded by a specific other scenario, you have a leak. Here are the three patterns that account for the majority of cases.

Pattern 1: Mutable state on a shared step class (Cucumber-JVM)

PicoContainer creates a new instance of each step class per scenario — but only if you let it. Engineers who wire step classes through a Spring ApplicationContext with default singleton scope break this guarantee silently.

// BAD: Spring singleton scope means one instance for the entire suite
@Component
public class CartSteps {
    private List<String> addedItems = new ArrayList<>(); // leaks across scenarios

    @When("the user adds {string} to the cart")
    public void addItem(String item) {
        addedItems.add(item);
    }
}
// GOOD: Use PicoContainer (no Spring) or explicitly scope to scenario
// With PicoContainer, CartSteps is instantiated fresh per scenario automatically.
// No annotation needed — just remove the Spring wiring.
public class CartSteps {
    private final List<String> addedItems = new ArrayList<>();
    // ...
}

The fix is to stop using Spring for step class lifecycle management unless you explicitly configure @Scope("cucumber-glue") (available since Cucumber-JVM 6). Run time impact is negligible; the architectural clarity is worth it.

Pattern 2: WebDriver stored on a Behave context fixture

Behave's before_scenario / after_scenario hooks in environment.py are the right place to manage browser lifecycle. The common mistake is initializing the driver in before_feature or before_all to save startup time, then never resetting page state between scenarios.

# BAD: driver lives for the entire feature file
def before_feature(context, feature):
    context.driver = webdriver.Chrome()

def after_feature(context, feature):
    context.driver.quit()
# GOOD: scenario-scoped driver
def before_scenario(context, scenario):
    context.driver = webdriver.Chrome(options=chrome_options())

def after_scenario(context, scenario):
    context.driver.quit()

Yes, this costs ~1.5–2 seconds per scenario for browser startup. On a suite of 200 scenarios that's ~5–6 minutes of overhead. The trade-off is deterministic isolation. If that cost is unacceptable, use a Screenplay-style actor model with a pooled, reset-on-loan browser rather than a shared singleton — the pool enforces per-scenario state boundaries without paying full startup cost each time. Run time dropped from 18 minutes to 4 in one internal suite after moving from feature-scoped to pooled scenario-scoped drivers.

Pattern 3: After-hook teardown that silently swallows exceptions

This one is insidious because it looks correct. The After hook exists, the resource appears to be released, but an exception during teardown causes the hook to exit early, leaving the resource in a dirty state for the next scenario.

# Python / Behave — silent failure pattern
def after_scenario(context, scenario):
    try:
        context.db_connection.rollback()
        context.db_connection.close()
    except Exception:
        pass  # silently swallows a failed rollback — next scenario inherits dirty data
# GOOD: log and re-raise, or use a finally block
def after_scenario(context, scenario):
    try:
        context.db_connection.rollback()
    finally:
        context.db_connection.close()  # always closes, even if rollback fails

The broader teardown ordering problem — especially when multiple fixtures are registered — is covered in depth in the article on fixture teardown order and silent state corruption. The short version: always use finally, always log teardown exceptions, and never assume a resource is clean because no exception was raised during setup.

Where Senior Engineers Still Get Caught

The first frustration is hook scope creep. A @Before hook tagged @wip gets refactored; the tag is removed from scenarios but the hook remains in scope for a broader tag set due to inheritance rules. The hook now initializes state for scenarios that don't expect it, and the World carries that unexpected initialization into steps. This is distinct from the World leak itself but compounds it — the World isn't just leaking, it's being pre-populated incorrectly. Understanding how tag inheritance silently widens hook scope is a prerequisite for diagnosing this class of bug.

The second frustration is dependency injection framework mismatch. Teams migrating from Cucumber-JVM 5 (PicoContainer default) to Cucumber-JVM 7 with Spring or Guice often carry over step class designs that assumed fresh instantiation. The DI framework changes the lifecycle rules, but no compile-time error surfaces. The suite runs, most scenarios pass, and the three that fail are attributed to "environment issues." Audit your step class scoping explicitly after any DI framework upgrade — don't rely on green builds as confirmation.

Myths That Let World Leaks Survive Code Review

Myth 1: "Our After hooks clean everything up, so leaks can't happen." After hooks clean up what they know about. They don't clean up static fields, thread-locals, or objects held by a DI container with a longer lifecycle than the scenario. The World object being re-instantiated is not the same as every object the World ever referenced being garbage-collected. If your step classes hold any mutable state outside constructor parameters, you have latent leak risk regardless of hook hygiene.

Myth 2: "This only matters for parallel execution." Sequential execution hides leaks; it doesn't eliminate them. A scenario that passes only because it always runs after a specific other scenario is still broken — it just won't tell you until someone adds a new scenario, reorders the suite, or runs a subset in isolation. The correct mental model is that every scenario must be able to run as the first and only scenario in the suite. If you can't run a single scenario in isolation with --name "exact scenario title" and get a green result, the suite has a state dependency. That's a defect, not a CI configuration problem.

Start by running your suite with randomized scenario order and --dry-run disabled. Any failure that doesn't reproduce in isolation is a World leak candidate. From there, audit step class scoping in your DI config, move resource initialization to before_scenario / After hooks, and wrap every teardown in a finally block. Once the suite is deterministically isolated, the next metric worth tracking is mean-time-to-detect on newly introduced flakiness — a clean baseline makes regressions visible within a single CI cycle.

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