Sharing State Between Steps — Dependency Injection

9 min read

A login scenario has Given steps in LoginSteps.java and Then steps in DashboardSteps.java. Both need the same WebDriver instance. A checkout scenario stores a generated order ID in one step and asserts on it in a later step. Step definitions can't reach into each other — so how does state flow between them?

This lesson answers that question properly. Static fields are the wrong answer. Dependency injection is the right one.

Why you can't use instance fields across classes

Cucumber creates a new instance of every step definition class for each scenario. That's by design — it guarantees scenario isolation. But it also means you can't write:

public class SharedState {
    public static WebDriver driver;  // ❌ static — persists across scenarios
}

A static field survives the scenario boundary. Scenario 2 picks up the browser session that scenario 1 quit, gets a NoSuchSessionException, and you spend an hour debugging a problem that's architectural, not code. Static is a shortcut that breaks isolation and parallelism at the same time.

Option 1: instance variables (one-class scenarios)

When all steps for a scenario live in the same class, instance fields are fine:

public class LoginSteps {
    private WebDriver driver;
    private String authToken;
 
    @Before
    public void setup() {
        driver = new ChromeDriver();
    }
 
    @Given("the user is on the login page")
    public void onLoginPage() {
        driver.get(System.getProperty("baseUrl") + "/login");
    }
 
    @When("the user logs in with {string} and {string}")
    public void login(String email, String password) {
        driver.findElement(By.id("email")).sendKeys(email);
        driver.findElement(By.id("password")).sendKeys(password);
        driver.findElement(By.id("submit")).click();
    }
 
    @Then("the user should be on the dashboard")
    public void verifyDashboard() {
        assertTrue(driver.getCurrentUrl().contains("/dashboard"));
    }
 
    @After
    public void teardown() {
        if (driver != null) driver.quit();
    }
}

This works cleanly — but only while all related steps stay in one class. As soon as a second class needs the driver, instance fields stop working.

PicoContainer is a lightweight DI container bundled with cucumber-picocontainer. Add the dependency:

<dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-picocontainer</artifactId>
    <version>7.18.0</version>
    <scope>test</scope>
</dependency>

No configuration needed — just add the artifact. Cucumber detects it automatically.

Step 1 — create a shared context class:

package context;
 
import org.openqa.selenium.WebDriver;
import java.util.HashMap;
import java.util.Map;
 
public class TestContext {
    private WebDriver driver;
    private String authToken;
    private final Map<String, Object> scenarioData = new HashMap<>();
 
    public WebDriver getDriver() { return driver; }
    public void setDriver(WebDriver driver) { this.driver = driver; }
 
    public String getAuthToken() { return authToken; }
    public void setAuthToken(String token) { this.authToken = token; }
 
    public void put(String key, Object value) { scenarioData.put(key, value); }
    public Object get(String key) { return scenarioData.get(key); }
 
    public void quitDriver() {
        if (driver != null) {
            driver.quit();
            driver = null;
        }
    }
}

Step 2 — inject it via constructors:

public class LoginSteps {
    private final TestContext context;
 
    public LoginSteps(TestContext context) {
        this.context = context;
    }
 
    @Given("the user is on the login page")
    public void onLoginPage() {
        context.getDriver().get(System.getProperty("baseUrl") + "/login");
    }
 
    @When("the user logs in with {string} and {string}")
    public void login(String email, String password) {
        WebDriver driver = context.getDriver();
        driver.findElement(By.id("email")).sendKeys(email);
        driver.findElement(By.id("password")).sendKeys(password);
        driver.findElement(By.id("submit")).click();
    }
}
 
public class DashboardSteps {
    private final TestContext context;
 
    public DashboardSteps(TestContext context) {
        this.context = context;
    }
 
    @Then("the user should be on the dashboard")
    public void verifyDashboard() {
        assertTrue(context.getDriver().getCurrentUrl().contains("/dashboard"));
    }
}
 
public class Hooks {
    private final TestContext context;
 
    public Hooks(TestContext context) {
        this.context = context;
    }
 
    @Before("@ui")
    public void setupBrowser() {
        WebDriverManager.chromedriver().setup();
        context.setDriver(new ChromeDriver());
    }
 
    @After
    public void teardown(Scenario scenario) {
        if (scenario.isFailed() && context.getDriver() != null) {
            byte[] screenshot = ((TakesScreenshot) context.getDriver())
                .getScreenshotAs(OutputType.BYTES);
            scenario.attach(screenshot, "image/png", scenario.getName());
        }
        context.quitDriver();
    }
}

PicoContainer sees that LoginSteps, DashboardSteps, and Hooks all declare TestContext as a constructor parameter. It creates one TestContext instance per scenario and injects the same object into all three classes. State flows between classes through that shared object. At scenario end, the instance is discarded — the next scenario gets a fresh one.

Storing scenario data between steps

The scenarioData map in TestContext is a catch-all for values that steps need to pass forward:

@When("the user creates an order for {string}")
public void createOrder(String product) {
    String orderId = orderService.create(product);
    context.put("orderId", orderId);    // store for later steps
}
 
@Then("the order confirmation page should show the order ID")
public void verifyOrderId() {
    String expectedId = (String) context.get("orderId");
    assertTrue(orderPage.getConfirmationText().contains(expectedId));
}

The map trades type safety for flexibility. For frequently-used values (driver, auth token), use typed fields. For step-to-step transient data (generated IDs, extracted values), the map works well.

Why PicoContainer, not Spring or Guice?

Cucumber supports multiple DI containers: PicoContainer, Spring, Guice, Weld, and others. PicoContainer is the default recommendation for test projects because:

  • Zero configuration — no XML, no annotations beyond the constructor parameter declaration
  • No classpath scanning or context initialisation overhead
  • Ships as cucumber-picocontainer — no separate Spring Boot or Guice setup
  • Purpose-built for test scenarios: create, inject, discard

Use Spring injection if your project already uses Spring (e.g., you want to autowire production beans into step definitions for integration testing). For standalone BDD framework projects, PicoContainer is the lighter choice.

The DI flow at a glance

TestContext
  • – LoginSteps
  • – DashboardSteps
  • – CheckoutSteps
  • – Hooks
  • – WebDriver
  • – Auth token
  • – Scenario data map
  • – New instance per scenario
  • – Discarded after @After
  • – No leakage between scenarios
  • PicoContainer (automatic) –
  • No configuration needed –

⚠️ Common mistakes

  • Static fields as an "easy" shortcut. Static WebDriver fields break isolation and cause NoSuchSessionException in parallel runs. The correct fix — PicoContainer — takes 5 minutes to set up once and never breaks again.
  • TestContext with a no-arg constructor called new TestContext() inside step definitions. Each class calling new TestContext() creates its own instance — no sharing happens. Always inject via the constructor; never instantiate inside the step definition class.
  • Forgetting cucumber-picocontainer in pom.xml. Without the artifact, Cucumber won't inject constructor parameters — it will try to call a no-arg constructor and throw NoSuchMethodException if none exists. The error message is unhelpful; check the dependency first.
  • Mutable shared state without cleanup. If TestContext.scenarioData holds a value from scenario 1 and you rely on it in scenario 2, the test is order-dependent. Always clear transient data in @After or use a fresh map per scenario (PicoContainer's fresh-instance-per-scenario guarantees this if scenarioData is an instance field, not a static one).

🎯 Practice task

Wire a multi-class step definition project with PicoContainer. 40–50 minutes.

  1. Add cucumber-picocontainer to your pom.xml. Create context/TestContext.java with fields for WebDriver, authToken, and a Map<String, Object> for ad-hoc data.
  2. Refactor your LoginSteps.java to receive TestContext via constructor injection. Move driver creation to Hooks.java with @Before("@ui").
  3. Create DashboardSteps.java with a @Then step that verifies the dashboard URL. It should receive the same TestContext via constructor and read the driver from it.
  4. Run a login scenario that involves steps in both LoginSteps and DashboardSteps. Confirm both classes share the same driver (no NullPointerException, dashboard assertion passes).
  5. Add a @When step that stores a value in context.put("key", value) and a @Then step (in a different step definition class) that retrieves it with context.get("key"). Confirm the value flows across the class boundary.
  6. Stretch: run two scenarios back to back and add a log line in @Before that prints the TestContext object hash code. Confirm a different hash code appears for each scenario — proving PicoContainer creates a fresh instance per scenario.

Next lesson: combining Cucumber with the Page Object Model for a clean, layered BDD architecture.

// tip to track lessons you complete and pick up where you left off across devices.