Sharing State in Cucumber-JVM: PicoContainer vs Spring DI
Cucumber-JVM scenarios often span multiple step definition classes. A login step puts a session token somewhere. A checkout step needs that token. A verification step needs the order ID that checkout returned. Without a deliberate state-sharing strategy, you end up with static fields, thread-local hacks, or a single god-class holding every step definition.
The right answer is dependency injection. Cucumber-JVM ships with official plugins for several DI containers. The two you'll actually choose between in practice are PicoContainer and Spring. This post covers both in depth — how they work, how to set them up, what they do differently, and which one belongs in your project.
Why State Sharing Is Harder Than It Looks
A Cucumber scenario runs across potentially many step definition classes. Each @Given, @When, @Then method lives in whatever class you put it in. Cucumber instantiates those classes fresh for every scenario — which is the right default, but it means you cannot rely on instance fields surviving across steps unless all steps live in the same class.
Sharing data through constructor arguments is the correct mechanism, and that's exactly what a DI container enables. The container creates one instance of each class per scenario and wires them together. Any two classes that declare the same type as a constructor parameter get the same instance.
Static fields "work" but they break parallel execution immediately, since multiple scenarios running concurrently will stomp each other's data. Thread-locals work for parallel execution but require careful cleanup and make the code hard to follow. DI containers solve both problems cleanly.
PicoContainer: Zero Configuration, Maximum Simplicity
PicoContainer is a micro-container. It does exactly one thing: given a set of classes, it figures out how to construct them by inspecting constructor parameter types, then creates and wires instances. There is no XML, no annotations to learn, no application context to configure.
Adding PicoContainer
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-picocontainer</artifactId>
<version>7.15.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.picocontainer</groupId>
<artifactId>picocontainer</artifactId>
<version>2.15</version>
<scope>test</scope>
</dependency>That's the entire setup. No runner configuration, no annotations on your classes.
Defining a Shared State Object
Create a plain Java class to hold scenario-scoped state:
public class ScenarioContext {
private String authToken;
private String orderId;
private int lastResponseStatus;
public String getAuthToken() { return authToken; }
public void setAuthToken(String token) { this.authToken = token; }
public String getOrderId() { return orderId; }
public void setOrderId(String id) { this.orderId = id; }
public int getLastResponseStatus() { return lastResponseStatus; }
public void setLastResponseStatus(int status) { this.lastResponseStatus = status; }
}No annotations. No interface to implement. Just a class.
Injecting Into Step Definitions
public class AuthSteps {
private final ScenarioContext ctx;
public AuthSteps(ScenarioContext ctx) {
this.ctx = ctx;
}
@Given("I am logged in as {string}")
public void loginAs(String username) {
String token = AuthClient.login(username, "password");
ctx.setAuthToken(token);
ctx.setLastResponseStatus(200);
}
}public class OrderSteps {
private final ScenarioContext ctx;
private final HttpClient client;
public OrderSteps(ScenarioContext ctx, HttpClient client) {
this.ctx = ctx;
this.client = client;
}
@When("I place an order for {string}")
public void placeOrder(String product) {
Response response = client.post(
"/orders",
Map.of("product", product),
ctx.getAuthToken()
);
ctx.setOrderId(response.body("id"));
ctx.setLastResponseStatus(response.status());
}
@Then("the order should be confirmed")
public void verifyOrder() {
assertThat(ctx.getLastResponseStatus()).isEqualTo(201);
assertThat(ctx.getOrderId()).isNotNull();
}
}PicoContainer sees that both AuthSteps and OrderSteps request ScenarioContext. It creates one ScenarioContext instance per scenario and passes it to both constructors. Same instance — shared state, zero configuration.
Multiple Shared Objects
You can inject as many shared objects as you need:
public class VerificationSteps {
private final ScenarioContext ctx;
private final DatabaseHelper db;
private final ApiClient api;
public VerificationSteps(ScenarioContext ctx, DatabaseHelper db, ApiClient api) {
this.ctx = ctx;
this.db = db;
this.api = api;
}
// ...
}PicoContainer constructs DatabaseHelper and ApiClient if they have no-arg constructors or if their own dependencies are resolvable. The entire graph is wired automatically.
Cleanup With Hooks
PicoContainer creates a new container per scenario, so cleanup often happens automatically. But if your shared objects hold resources — open connections, temp files, browser instances — use a hook:
public class ScenarioContext {
// fields as before
@After
public void cleanup(Scenario scenario) {
if (scenario.isFailed()) {
// log state for debugging
}
// release resources
}
}Cucumber recognizes @After on any class in the step definition package. Since ScenarioContext is instantiated by PicoContainer, its hooks run correctly.
Spring DI: Full Application Context
Spring integration makes sense when your application is already Spring-based, when you need to inject real Spring beans (repositories, services, configured RestTemplate or WebClient instances), or when your team already knows Spring deeply and wants to reuse that knowledge in tests.
Adding the Spring Plugin
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-spring</artifactId>
<version>7.15.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>For Spring Boot projects, include spring-boot-starter-test which pulls in spring-test transitively.
The Context Configuration Class
You need exactly one class annotated with @CucumberContextConfiguration. This is what tells Cucumber how to bootstrap the Spring context:
@CucumberContextConfiguration
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class CucumberSpringConfiguration {
// This class can be empty.
// SpringBootTest will load your full application context.
}For integration tests that don't need the full application:
@CucumberContextConfiguration
@ContextConfiguration(classes = TestConfig.class)
public class CucumberSpringConfiguration {
}@Configuration
public class TestConfig {
@Bean
public ApiClient apiClient() {
return new ApiClient("http://localhost:8080");
}
@Bean
public DatabaseHelper databaseHelper(DataSource dataSource) {
return new DatabaseHelper(dataSource);
}
}@ScenarioScope: The Critical Annotation
By default, Spring beans are singletons — one instance shared across all tests. That's the opposite of what you want for scenario state. Use @ScenarioScope to create a new bean instance for each scenario:
@Component
@ScenarioScope
public class ScenarioContext {
private String authToken;
private String orderId;
private int lastResponseStatus;
// getters and setters
}@ScenarioScope is provided by cucumber-spring. It registers a custom Spring scope that creates and destroys beans per scenario. At scenario start, a new ScenarioContext is created. At scenario end, it's destroyed. This is the Spring equivalent of PicoContainer's per-scenario instantiation.
Injecting Into Step Definitions
@Component
public class AuthSteps {
@Autowired
private ScenarioContext ctx;
@Autowired
private AuthService authService;
@Given("I am logged in as {string}")
public void loginAs(String username) {
String token = authService.authenticate(username, "password");
ctx.setAuthToken(token);
}
}Step definition classes must be Spring components. Annotate them with @Component (or a stereotype annotation). Spring will manage their lifecycle and inject dependencies.
@Component
public class OrderSteps {
@Autowired
private ScenarioContext ctx;
@Autowired
private OrderRepository orderRepository;
@Autowired
private TestRestTemplate restTemplate;
@When("I place an order for {string}")
public void placeOrder(String product) {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(ctx.getAuthToken());
ResponseEntity<OrderDto> response = restTemplate.exchange(
"/orders",
HttpMethod.POST,
new HttpEntity<>(Map.of("product", product), headers),
OrderDto.class
);
ctx.setOrderId(response.getBody().getId());
ctx.setLastResponseStatus(response.getStatusCode().value());
}
}Notice that orderRepository is a real Spring Data repository here. In a @SpringBootTest context, this connects to whatever database is configured — in-memory H2, a Testcontainers PostgreSQL, etc. This is where Spring integration earns its complexity: you get full access to the application's real beans.
Resetting @ScenarioScope State
@ScenarioScope handles destruction automatically at scenario end. If you need explicit reset logic:
@Component
@ScenarioScope
public class ScenarioContext implements DisposableBean {
// fields...
@Override
public void destroy() {
// runs at end of each scenario
authToken = null;
orderId = null;
}
}Or use a @After hook in a step definition class to call cleanup methods on injected beans.
Parallel Execution and Thread Safety
Both approaches are safe for parallel execution, but for different reasons and with different caveats.
PicoContainer creates an entirely separate container per scenario. Each scenario gets its own object graph. There is no shared mutable state between scenarios by construction. Parallel execution works as long as your external dependencies (database, API server) can handle concurrent requests.
Spring caches the application context across test runs (including parallel scenarios) unless you force a context refresh. The Spring context itself is shared. @ScenarioScope beans are per-scenario, but singleton beans are shared. This means:
@ScenarioScope ScenarioContext— safe, per-scenario@Autowired SomeService someService— shared, must be stateless or thread-safe@Autowired JdbcTemplate jdbcTemplate— shared, stateless, fine@Autowiredany bean with instance-level mutable state — thread-safety problem
In practice, well-designed Spring services are stateless, so this isn't usually a problem. But if you have stateful Spring beans, you need to make them @ScenarioScope as well.
For truly parallel Cucumber execution with Spring, add:
# junit-platform.properties
cucumber.execution.parallel.enabled=true
cucumber.execution.parallel.config.strategy=fixed
cucumber.execution.parallel.config.fixed.parallelism=4And verify that all scenario-level state uses @ScenarioScope.
Comparison Table
| Aspect | PicoContainer | Spring DI |
|---|---|---|
| Setup effort | Add 2 dependencies, done | Config class + annotations required |
| Learning curve | Minimal — just constructor injection | Requires Spring knowledge |
| Spring bean access | No | Full access to real application beans |
| Scenario isolation | Automatic (new container per scenario) | Requires @ScenarioScope on state beans |
| Startup time | Fast | Slower (full context load) |
| Database integration | Manual (inject your own helpers) | Spring Data, JPA, Testcontainers — all work |
| Parallel safety | Safe by design | Safe with stateless singletons + @ScenarioScope |
| Suitable for | Greenfield, non-Spring projects, simple APIs | Spring Boot projects, enterprise codebases |
When to Choose PicoContainer
Choose PicoContainer when:
- Your test helpers and clients are plain Java objects, not Spring beans
- You want tests to start fast and stay lightweight
- Your team doesn't use Spring in the application under test
- You have a simple to moderate number of shared state objects
- You want maximum clarity — what gets constructed is visible from constructor signatures alone
PicoContainer's constraint is also its strength: it only does constructor injection, and it only knows about classes in your test classpath. If you need to inject a bean that requires configuration — a pre-built HTTP client, a database pool, a message broker — you have to configure it yourself in your shared state class or a factory. That's manual work, but it's explicit work.
When to Choose Spring
Choose Spring when:
- Your application is Spring Boot and you want tests to exercise real beans
- You need
@Transactionaltest rollback behavior - You're using Spring Data repositories and want them available in step definitions
- Your team is fluent in Spring and prefers annotation-driven configuration
- You need profiles (
@ActiveProfiles) to switch between test environments
The cost is startup time and cognitive overhead. A full @SpringBootTest context can take 10–30 seconds to load. Cucumber shares the Spring context across scenarios in the same JVM, which mitigates this — the context loads once — but if you need multiple context configurations across your suite, each distinct configuration creates a separate context.
Real-World Pattern: Layered Shared State
In larger suites, a single ScenarioContext class becomes a dumping ground. A cleaner pattern is to split state by domain:
// PicoContainer example — all injected via constructor
public class CheckoutSteps {
private final AuthContext authCtx;
private final CartContext cartCtx;
private final OrderContext orderCtx;
public CheckoutSteps(AuthContext authCtx, CartContext cartCtx, OrderContext orderCtx) {
this.authCtx = authCtx;
this.cartCtx = cartCtx;
this.orderCtx = orderCtx;
}
}With PicoContainer, each context class is a separate file, constructed by the container. With Spring, each is a @Component @ScenarioScope bean. Either way, the step definition declares exactly which domains it touches, making dependencies visible and the class easier to test.
Common Mistakes
Using static fields alongside DI. Pick one. Static fields break parallel execution and make the DI setup misleading.
Forgetting @ScenarioScope in Spring. Without it, your state bean is a singleton. The first scenario sets the auth token; the second scenario starts with the first scenario's auth token. This produces bizarre intermittent failures that are hard to diagnose.
Putting @CucumberContextConfiguration on multiple classes. Cucumber requires exactly one such class per test run. Multiple classes cause an ambiguity error at startup.
Injecting the Spring ApplicationContext directly in step definitions. This creates tight coupling between your steps and the framework. Inject the specific bean you need, not the context itself.
Not cleaning up resources in hooks. Browser instances, open file handles, and database connections don't vanish when the scenario ends just because the state object does. Use @After hooks or DisposableBean to release resources explicitly.
BDD suites verify documented behaviors well, but production monitoring is a different problem. HelpMeTest complements your Cucumber suite with AI-powered E2E monitoring in plain English — no code, usage-based pricing with no base fee — so regressions get caught in production before users report them.