skip to content

In a parallel Cucumber-JVM run, what goes wrong when step definitions keep scenario state in a static field?

level: seniorimportance: should knowfreq 54%

answer

  1. One slot for the whole process
  2. Scenarios overwrite each other's values
  3. Green alone, red in the suite
  4. Thread safety does not restore isolation
  5. Per-scenario instance is the real fix

basics

~10 s

A static field is one slot per class for the whole JVM, so concurrent scenarios read and write the same value. Scenarios overwrite each other's data, and the failure passes when rerun alone.

solid answer

~50 s

When the runner executes scenarios in parallel, several scenarios run on different threads **inside the same JVM**. A `static` field has exactly one slot per class there, so it is not per scenario and not per thread — every concurrently running scenario reads and writes the same value. One scenario stores case `CR-2291-B`, another overwrites it with `CR-2288-A` a few milliseconds later, and the first scenario's `Then` asserts against data it never created. The signature is unmistakable: the failing scenario passes when run on its own, a different scenario fails on the next run, and the assertion message contains a value that appears nowhere in the failing scenario's own feature file. `volatile` and `synchronized` do not help, because the defect is shared *identity*, not unsafe publication. The fix is one state object per scenario, injected into the step definition classes that need it.

code

java · 25 lines
java
// BEFORE - one slot per class, shared by every scenario in the JVM
public class DocketSteps {

    private static String scheduledCase;

    @Given("case {string} is booked into courtroom 4B")
    public void caseIsBooked(String caseNumber) {
        scheduledCase = caseNumber;
    }
}

// AFTER - one HearingContext per scenario, injected into every class that needs it
public class DocketSteps {

    private final HearingContext hearing;

    public DocketSteps(HearingContext hearing) {
        this.hearing = hearing;
    }

    @Given("case {string} is booked into courtroom 4B")
    public void caseIsBooked(String caseNumber) {
        hearing.bookCase(caseNumber);
    }
}

go deeper

for a junior

Recall that a static field is one value for the whole process, not one per scenario, so scenario state must never live there. Keep it in the object the runner gives you for each scenario.

for a middle

Explain the mechanics: concurrent scenarios share the single slot, the last writer wins, and thread-safety keywords do not create per-scenario copies. Be able to contrast that with an injected object built per scenario.

for a senior

Show the diagnosis. Passes alone, fails in the suite, a different victim each run, values from another feature file in the assertion message, and a serial rerun as the cheap confirmation. Then explain why a retry step hides it.

for a principal

Own the prevention: a convention that scenario state is always injected, a review or static-analysis rule against mutable statics in glue code, and a policy on rerunning failed scenarios in CI, which is what lets this class of defect survive for months.

## Why a static field is not per-scenario A `static` field belongs to the class, and the class is loaded once per classloader. In a Cucumber-JVM run that means **one slot for the whole JVM**, no matter how many scenarios run. Cucumber's own instances are scoped correctly — glue classes are rebuilt for each scenario, and an injected state object is created per scenario — but a static field sidesteps all of that. It is the one piece of state the scenario boundary does not reach. Serially, this is already a bug: scenario 12 sees whatever scenario 11 left behind, so scenarios become order-dependent and a suite reordering breaks them. In parallel it becomes a race. Two threads interleave their reads and writes on a single slot, and the losing scenario asserts against another scenario's data. Note what parallelism does **not** change: it does not create the bug, it makes it constant. Teams usually discover the static field on the day they turn parallelism on, and then blame parallelism. ## A worked incident A courtroom-scheduling suite of 37 feature files ran serially in just over nineteen minutes against a pipeline budget of eleven minutes — a shared reseed of the docket alone accounted for 84 seconds of it. The team switched the runner to execute scenarios in parallel, and the wall clock dropped under the budget. Over the next 143 pipeline runs, 19 failed with assertions like *expected hearing for CR-2288-A but was CR-2291-B*. Every failure was green when the scenario was rerun by itself. The cause was one line in a step definition class: ```java private static String scheduledCase; ``` added years earlier so a second step definition class could read it. Serially it had been invisible, because scenarios happened to run in an order where each one overwrote the field before reading it. Under four threads, two scenarios routinely wrote between one another's write and read. ## The failure signature Learn to recognise this without a debugger: 1. **Passes alone, fails in the suite.** Isolation removes the other scenarios, which is the whole diagnosis in one step. 2. **A different scenario fails each run.** The victim is whoever lost the race, not whoever holds the bug. 3. **Rerunning only the failures is green**, so a "rerun failed scenarios" step in CI hides the defect indefinitely — the most expensive part of the whole failure mode. 4. **Assertion messages contain values from another feature file** — a case number that appears nowhere in the failing scenario or its `Examples` rows. 5. **Failure rate scales with thread count** and drops to zero when the run is forced back to a single thread. That last check is the cheap confirmation: run the suite serially. If it goes green while the code is unchanged, you are looking at shared state, not a flaky environment. ## Why the usual reflexes do not fix it | Attempted fix | What it actually does | | --- | --- | | `volatile` | Guarantees every thread sees the *same* value promptly — it strengthens the sharing that is the bug | | A lock around access | Serialises access to one slot; the value is still one value for all scenarios | | `ThreadLocal` | Removes the race, but a reused worker thread carries the value into the next scenario unless it is cleared at the scenario boundary | | Adding a retry | Hides the defect and makes it permanent | | Blaming the environment | Costs days; the serial run disproves it in minutes | `ThreadLocal` deserves the extra sentence, because it is the plausible-sounding answer that half-works. It genuinely stops cross-thread interference, but Cucumber scenarios are executed on pooled threads, so a thread that finishes one scenario picks up another, and stale state travels with it. If you use one, it must be cleared at the end of every scenario, at which point you have hand-built a worse version of a scenario-scoped object. ## The fix, and what may legitimately stay static Replace the static field with an object whose lifetime is the scenario, and let the step definition classes ask for it: - On the JVM, a plain state class injected through the container in use — one instance per scenario, injected into every step definition class that needs it. - In cucumber-js, the `World`, which exists for exactly this purpose. Static is not banned outright; it is banned for **mutable scenario state**. These are fine: - Immutable constants and configuration read once. - A thread-safe, genuinely process-wide resource — a container port registry, a connection pool — that no scenario mutates in a scenario-specific way. - A lazily-initialised singleton whose initialisation is idempotent and whose state is never scenario-specific. ## cucumber-js: the same bug with a different shape cucumber-js runs parallel scenarios in separate worker **processes**, so a module-level variable is not shared between workers and you will not see a data race. That makes people believe module globals are safe there. They are not: within one worker, scenarios run one after another in the same module instance, so a module-level variable carries state from one scenario to the next. The result is order-dependent failures rather than races — harder to reproduce, because the order depends on how work was distributed across workers that run. Same rule, different mechanism: per-scenario state lives on the World.

  • Why does making that static field volatile or synchronizing access not fix it?
    Both address memory visibility and atomicity, not identity. After either change there is still exactly one value shared by every scenario, so the last writer still wins and the loser still asserts against another scenario's data. You would have converted a race into a deterministic overwrite. Isolation requires one instance per scenario, which only scoping gives you.
  • cucumber-js runs parallel scenarios in separate processes, so is a module-level variable safe there?
    No. Separate worker processes mean no data race between workers, but each worker runs many scenarios in sequence inside one module instance, so the variable carries state from one scenario into the next. The failures are order-dependent rather than racy, which makes them harder to reproduce. Scenario state belongs on the World.
  • How would you prove state bleed is the cause rather than a flaky environment?
    Three cheap checks. Run the failing scenario alone — green points away from the environment. Rerun the whole suite on a single thread with no code change — green confirms shared state. Then log the scenario name beside the suspect value at write and read time; the failing read will show a name that is not the scenario reading it.

A static field is a single whiteboard in the courthouse lobby: every clerk writes the case they are handling on it, and each one reads back whoever wrote last.

saying these in an interview costs you the question

  • Blames the browser, the network or a slow environment
  • Adds a retry so the intermittent failure disappears
  • Says static gives each scenario its own copy
  • Thinks volatile or a lock restores scenario isolation
  • Assumes cucumber-js module globals are safe between scenarios
  • Believes the bug only exists when running in parallel