Sharing State Between Steps in Cucumber JVM
What Does Sharing State Between Steps Mean?
Sharing state between steps in Cucumber JVM means passing information created in one step definition to another step definition within the same scenario. A Cucumber scenario is written as a connected business flow, but each Gherkin step is implemented as a separate Java method. Because normal local variables belong only to the method where they are declared, data created in one step is not automatically available in another step. A controlled state-sharing mechanism solves that problem.
In simple terms, data created in one step is reused by later steps in the same scenario. This shared information is often called scenario state. It may include a logged-in user, customer object, product selection, shopping cart, order ID, authentication token, API response, search result, generated email address, invoice number, payment details, uploaded file name, or any runtime value that later steps need. The important point is scope: the state belongs to the current scenario, not to the whole test suite.
State sharing is a normal need in real automation. A scenario rarely consists of fully isolated steps. A login step may produce a user session. A create customer step may produce a customer ID. A search step may produce a list of products. A checkout step may produce an order confirmation number. Later steps need those values to continue the scenario or verify the result. Sharing state provides a clean way to connect those steps without duplicating work or using unsafe global variables.
Good state sharing makes Cucumber automation readable and maintainable. The feature file remains business-focused, while the Java framework moves runtime data through the scenario in a predictable way. Poor state sharing, on the other hand, often leads to static variables, hidden dependencies, test leakage, and random failures during parallel execution.
Why State Sharing Is Needed
Consider a simple order placement scenario. The user logs in, places an order, and verifies the order confirmation. The login step creates a user session. The order step may need the logged-in user or session. The order step then generates an order ID. The verification step needs that order ID to confirm the correct order was created. This is a connected flow, so data must move from one step to another.
Scenario: Place an Order
Given the user logs in
When the user places an order
Then the order confirmation should be displayed
The execution flow is:
Login
|
v
Create User Session
|
v
Place Order
|
v
Generate Order ID
|
v
Verify Order
Without state sharing, the verification step would not know which order to validate. It might query the latest order, recreate data, or rely on hardcoded values. All of those approaches are weaker than storing the generated order in scenario-scoped state and retrieving it later. The scenario should validate the actual result produced during execution, not an unrelated or guessed value.
The Problem Without State Sharing
The problem becomes clear when looking at Java method scope. A variable declared inside one step definition is local to that method. It cannot be used directly inside another method. This is not a Cucumber limitation; it is normal Java behavior. But it affects how Cucumber step definitions are designed.
@Given("the user logs in")
public void login() {
User user = loginService.login();
}
@When("the user places an order")
public void placeOrder() {
// User object is unavailable here
}
The user variable exists only inside the login method. Once the method finishes, placeOrder cannot access it. A beginner may try to solve this by logging in again, using a static variable, or putting all steps in one huge method. Those solutions introduce other problems. Repeating login wastes time and may create inconsistent state. Static variables leak data across scenarios. Huge step methods reduce readability and reuse.
The better solution is scenario-scoped shared state. The login step stores the user in a shared context for the current scenario. The order step retrieves that user from the same context. The verification step retrieves the order created by the order step. The data stays available only while the scenario is executing.
How State Is Shared
State is usually shared through a scenario-scoped object such as ScenarioContext. Each step definition class receives access to the same context instance for the current scenario. A step can write data into the context, and later steps can read it. If a later step produces new data, it can write that too.
Given Step
|
v
Store Data
|
v
Shared State
|
v
When Step
|
v
Read Data
|
v
Store New Data
|
v
Then Step
|
v
Read Data
The shared state should be controlled and intentional. It should not be an uncontrolled global map where every object is placed just in case. Store values because later steps need them, because cleanup hooks need identifiers, or because assertions need the actual result of an earlier operation. If a value is used only inside one step, keep it local to that step.
Types of State Commonly Shared
Common shared state includes users, customers, products, shopping carts, orders, session IDs, JWT tokens, API responses, database records, search results, invoices, payment details, uploaded files, and generated identifiers. These values represent runtime facts created or selected while the scenario executes.
For example, a token returned by a login API can be stored and reused for several authenticated API calls. A product selected in a search step can be stored and compared on the cart page. An order created in a checkout step can be stored and verified on the confirmation page. A response returned by an API request can be stored and asserted in a Then step.
Only information needed by later steps should be shared. Storing too much state makes the context difficult to understand. A clean context tells the story of the scenario. It should contain meaningful objects, not every temporary calculation.
Scenario Scope
Shared state should be scenario-scoped. Scenario A creates state, uses it, and destroys it when the scenario ends. Scenario B gets a new state object. There should be no assumption that data from one scenario is available in another scenario. This keeps tests independent and repeatable.
Scenario A
|
v
Create State
|
v
Use State
|
v
Destroy State
Scenario B
|
v
Create New State
|
v
Use State
|
v
Destroy State
This is essential for parallel execution. If scenarios share the same static state, one scenario may overwrite another scenario's user, token, response, or order. The failures may appear random because they depend on timing. Scenario-scoped state prevents that by giving each scenario its own storage.
Example Using Instance Variables
A simple way to share state is to use instance variables inside the same step definition class. If the same class implements both steps and Cucumber creates one instance of that class per scenario, an instance field can hold data between methods.
public class LoginSteps {
private User user;
@Given("the user logs in")
public void login() {
user = loginService.login();
}
@Then("the username should be displayed")
public void verifyUsername() {
System.out.println(user.getUsername());
}
}
This can work for small examples, but it has limitations. It works only when the steps that need the data are in the same class and the object lifecycle is scenario-scoped. Real projects usually split steps across several classes such as LoginSteps, SearchSteps, CheckoutSteps, and OrderSteps. Instance variables in one class are not directly available in another class.
The Problem with Multiple Step Definition Classes
Large Cucumber projects separate step definitions by feature area or responsibility. Login steps may live in one class, search steps in another, checkout steps in another, and API validation steps in another. This keeps code organized, but it means one class cannot directly access another class's private instance variables.
public class LoginSteps {
private User user;
}
public class CheckoutSteps {
// Cannot directly access LoginSteps.user
}
A shared scenario-scoped object is required. This is where Scenario Context becomes the preferred pattern. Instead of trying to access fields across step classes, each step class receives the same context instance and reads or writes shared values through that context.
Using Scenario Context
A Scenario Context class is a dedicated object used to hold shared state for one scenario. It can have strongly typed fields, generic map storage, or a combination of both. The simplest version stores a user with getter and setter methods.
public class ScenarioContext {
private User user;
public void setUser(User user) {
this.user = user;
}
public User getUser() {
return user;
}
}
Every step definition that needs user state receives the same ScenarioContext instance. The login step stores the user. The checkout step retrieves it. The verification step retrieves it again if needed. This avoids static variables and keeps state scoped to the scenario.
Sharing a User Object
A common example is sharing the logged-in user. The Given step performs login and stores the returned user object. The When step retrieves the user and uses it to place an order or open a page. The same user object flows through the scenario.
@Given("the user logs in")
public void login() {
User user = loginService.login();
scenarioContext.setUser(user);
}
@When("the user places an order")
public void placeOrder() {
User user = scenarioContext.getUser();
checkoutPage.placeOrder(user);
}
This design is clearer than logging in repeatedly or storing user details in global variables. The step definitions show what they need, and the context provides the state created earlier in the scenario.
Sharing an API Response
API tests frequently share responses between steps. A When step sends a request and stores the response. A Then step retrieves that response and validates status code, body fields, headers, or schema. This keeps request execution and response validation separate while still connected.
Response response =
given()
.when()
.post("/users");
scenarioContext.setResponse(response);
Response response = scenarioContext.getResponse();
Assert.assertEquals(response.statusCode(), 201);
This pattern is common in Cucumber API automation. The scenario reads naturally: when a request is submitted, then the response should be valid. The Java implementation stores the actual runtime response so validation uses the real result.
Sharing an Order ID
Generated IDs are one of the strongest reasons for sharing state. When a service creates an order, the generated order ID is unknown before execution. Later steps must use the ID returned by the application, not a hardcoded value. Scenario state solves this cleanly.
Order order = orderService.createOrder();
scenarioContext.setOrder(order);
Order order = scenarioContext.getOrder();
Assert.assertNotNull(order.getId());
The verification step validates the actual order created during the scenario. Cleanup hooks can also use the same ID to delete the order after execution. This makes the test more reliable and repeatable.
Sharing an Authentication Token
Authentication tokens are commonly shared in API scenarios. A login step receives a token. Later API calls need that token in the Authorization header. Instead of logging in before every request, store the token once and reuse it during the scenario.
String token = authService.login();
scenarioContext.setToken(token);
String token = scenarioContext.getToken();
given()
.header("Authorization", "Bearer " + token)
.when()
.get("/users");
This keeps the scenario efficient and realistic. The same authenticated session is used for related actions. The token remains scoped to the scenario and is not shared globally.
Sharing Search Results
UI scenarios often need to share search results. A When step searches for a product and stores the returned list. A Then step verifies the list is not empty or contains expected values. If a later checkout step selects a product from that list, it can also retrieve the same stored results.
List<Product> products = searchPage.search("Laptop");
scenarioContext.setProducts(products);
List<Product> products = scenarioContext.getProducts();
Assert.assertFalse(products.isEmpty());
Sharing search results prevents repeated searches and keeps verification tied to the actual result produced in the scenario. This is better than querying the page again from scratch in every step.
Sharing State Using Dependency Injection
Large Cucumber JVM frameworks commonly use dependency injection to provide the same context instance across multiple step definition classes. PicoContainer, Spring, and Guice are common options. The framework creates a scenario-scoped context and injects it into LoginSteps, SearchSteps, CheckoutSteps, and other classes.
Scenario Context
|
v
Injected into
|
v
LoginSteps
SearchSteps
CheckoutSteps
Dependency injection avoids static variables and avoids forcing all steps into one class. Each step class stays focused on its own area, while still sharing scenario state through the injected context. This is a strong enterprise pattern.
Sharing State Versus Global Variables
Global variables and static fields are unsafe for scenario data. A static user, static response, or static token is shared across scenarios. If scenarios run in parallel, one scenario can overwrite another scenario's value. Even in sequential execution, stale values can leak from one scenario into another if they are not cleared correctly.
static User user;
static Response response;
Scenario Context is safer because it is scenario-specific. Each scenario gets its own context. Data is independent, easier to reason about, and more parallel-friendly. Avoid static variables for scenario state unless there is a very specific framework-level reason, and even then, keep business data out of static fields.
Sharing State Versus Data Tables
Data Tables and shared state solve different problems. Data Tables provide input data from the feature file. They are written before execution and describe values the scenario should use. Scenario state stores runtime data produced during execution. It contains values that may not exist until the application responds.
Feature File
|
v
Input Data
|
v
Execution
|
v
Generated Order ID
|
v
Scenario Context
A Data Table might provide product names or user details. Scenario Context might store the generated order ID after checkout. Do not confuse input examples with runtime state. Both are useful, but they belong to different phases of the scenario.
Sharing State Versus Doc Strings
Doc Strings provide multiline input to a step definition. They are useful for JSON payloads, XML, SQL, email bodies, and large text blocks. Scenario Context stores runtime objects and values that are produced or selected while the scenario runs. A Doc String may provide the request body. Scenario Context may store the response returned after sending that request.
The difference is simple: Doc Strings provide input; Scenario Context stores execution state. A clean framework uses each tool for its intended purpose.
Parallel-Safe State Sharing
Parallel execution requires careful state sharing. Each scenario must have its own state. If two scenarios share the same context object, static field, or global map, they can interfere with each other. Scenario A may store Order A, Scenario B may overwrite it with Order B, and Scenario A may verify the wrong order.
Use dependency injection, scenario-scoped objects, or thread-safe framework patterns so each scenario receives isolated storage. If ThreadLocal is used internally, clear it after the scenario ends to avoid stale data reuse. Step definitions should interact with a simple context API and should not manage low-level concurrency details directly.
State Sharing and Cleanup Hooks
Shared state is also useful for cleanup. When a scenario creates a customer, order, uploaded file, or API record, store the identifier in context. An @After hook can retrieve those identifiers and clean only the resources created by the current scenario.
scenarioContext.setOrder(order);
scenarioContext.setUploadedFileName(fileName);
This makes cleanup precise. The hook does not need to guess which records belong to the test. It uses the state captured during execution. This is safer for shared environments and parallel runs.
State Ownership Between Steps
State sharing becomes easier to maintain when each piece of state has a clear owner. The step that creates or receives a value should usually be responsible for storing it. The later step that needs the value should retrieve it, but it should not recreate it unless the scenario explicitly says so. For example, a login step owns the logged-in user or token. An order creation step owns the generated order. A search step owns the search results. This ownership makes the data flow easy to follow.
Clear ownership prevents accidental overwrites. If several steps write to the same context key without a clear reason, the scenario becomes difficult to debug. A verification step should usually not replace the order object. A cleanup step should usually not replace the user object. Steps should update shared state only when the scenario has genuinely produced a new value.
When state is intentionally updated, the code should make that clear. For example, an update customer step may retrieve the customer, submit changes, and store the updated customer object back into context. That is acceptable because the scenario's state has changed. The key is that the update should be deliberate and readable, not a side effect hidden inside unrelated code.
Customer customer = scenarioContext.getCustomer();
Customer updatedCustomer = customerService.updateCustomer(customer);
scenarioContext.setCustomer(updatedCustomer);
This kind of explicit state transition helps future maintainers understand what the scenario currently knows. It also helps when debugging failures because the context reflects the latest meaningful business object.
Required State and Clear Failure Messages
Some shared values are required. If an order verification step runs, the order should already exist in state. If an authenticated API call runs, the token should already exist. If a checkout step needs a selected product, the product should have been stored by an earlier step. When required state is missing, the framework should fail with a clear message instead of producing a vague null pointer exception later.
public Order getRequiredOrder() {
if (order == null) {
throw new IllegalStateException(
"Order is missing from scenario state. " +
"Ensure the order creation step executed first.");
}
return order;
}
This small design choice improves debugging a lot. A missing state error tells the tester that the scenario flow or step implementation is wrong. A null pointer inside a page object only says that something was null, often far away from the real cause. Required getters make state expectations explicit.
Not every value should be required. Cleanup hooks often deal with optional state because a scenario may fail before creating a resource. In cleanup, optional checks are appropriate. In business verification steps, required checks are usually better. The context API can support both patterns with methods such as getOrder() for optional access and getRequiredOrder() for mandatory flow data.
Keeping Shared State Readable in Large Scenarios
Large scenarios can accumulate many shared values. If the context holds user, product, cart, order, payment, invoice, response, token, address, and file data, it becomes harder to know which values are still relevant. This is one reason scenario granularity matters. A scenario that requires too much shared state may be testing too much behavior at once. Sometimes the better solution is to split the scenario rather than make the context larger.
When a scenario genuinely needs several related objects, use meaningful domain objects. A single CheckoutState object may be clearer than many separate primitive fields. An Order object is clearer than separate order ID, amount, status, and confirmation text values scattered across the context. Strong objects make the scenario state easier to understand.
public class CheckoutState {
private User user;
private Product product;
private Order order;
private PaymentDetails paymentDetails;
}
This does not mean every scenario needs a custom state object. It means state should be shaped around the business flow. If several values always move together, group them. If a value is used once, keep it local. If a scenario context starts to feel like a miscellaneous storage box, review the scenario design and context structure.
State Sharing and Step Reusability
Well-designed state sharing improves step reusability. A step such as When the user places an order can retrieve the user and selected product from context and store the created order. That step can be reused in multiple scenarios as long as the required state is prepared consistently. The step does not need hardcoded users or products, and it does not need to repeat setup.
However, reusable steps should not have hidden or surprising dependencies. If a step needs a logged-in user, that dependency should be clear from the scenario flow or from the step naming. A step that silently expects five context values can become fragile. Keep steps declarative and keep state dependencies logical. If the dependency is not obvious, consider changing the step wording or splitting the behavior.
Good state sharing supports readable Gherkin. It should not make scenarios mysterious. A reader should still understand that login happens before checkout, product selection happens before cart verification, and order creation happens before order confirmation. Context carries the values, but the scenario should still communicate the business flow.
Real-Time Enterprise Example
A practical enterprise framework may keep context, models, step definitions, services, and pages in separate packages. The context stores shared scenario objects such as user and order. Step definitions retrieve those objects as needed. Services and page objects perform business actions.
src
├── context
│ └── ScenarioContext.java
├── models
│ ├── User.java
│ └── Order.java
├── stepdefinitions
│ ├── LoginSteps.java
│ ├── CheckoutSteps.java
│ └── OrderSteps.java
├── services
└── pages
public class ScenarioContext {
private User user;
private Order order;
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public Order getOrder() {
return order;
}
public void setOrder(Order order) {
this.order = order;
}
}
The execution is: login, store user, checkout, retrieve user, create order, store order, verify order, retrieve order. Each step reuses state created by previous steps without relying on globals.
Common Mistakes
Using Static Variables
Static variables cause data leakage between scenarios and are unsafe during parallel execution. Use scenario-scoped context instead.
Recreating Objects
Repeatedly logging in, recreating customers, or resending requests because state was not saved wastes time and can create inconsistent results. Reuse the object created earlier in the scenario.
Storing Unnecessary Data
Do not store every local variable, temporary calculation, or object used only in one step. Store only values required by later steps or cleanup.
Mixing Business Logic into Context
Scenario Context should store and retrieve data. It should not contain operations such as context.login(), context.purchase(), or context.verifyOrder(). Business logic belongs in services, page objects, and step definitions.
Forgetting Scenario Scope
Never assume data from one scenario is available in another. Each scenario should start with fresh state. Cross-scenario dependencies make tests fragile.
Best Practices
Share only the data required by subsequent steps. Use a dedicated Scenario Context object instead of static variables. Prefer sharing strongly typed POJOs over scattered primitive values when the data represents a business concept. For example, share an Order object rather than separate order ID, product name, price, and status strings unless separate values are genuinely clearer.
Use dependency injection to provide the same context instance across step definition classes. Keep state scoped to a single scenario to support isolation and parallel execution. Remove unnecessary data from the context or avoid storing it in the first place. Keep business logic out of the context and treat it as a data carrier.
Fail clearly when required state is missing. If a step expects an order in context and no order exists, the framework should report a meaningful error instead of throwing a vague null pointer later in a page object. Clear state errors make debugging faster.
Interview-Ready Explanation
Sharing state between steps allows data created in one step definition to be reused by subsequent step definitions within the same Cucumber scenario. Since each step is implemented as a separate Java method, local variables cannot be shared directly across steps. Enterprise Cucumber frameworks typically use a scenario-scoped Scenario Context object, often injected through dependency injection, to store and retrieve shared objects such as users, tokens, API responses, orders, search results, and generated IDs.
Shared state should remain scenario-scoped so tests stay isolated and safe for parallel execution. Static variables should be avoided because they introduce shared state across scenarios and lead to unpredictable failures. The context should store only data needed by later steps or cleanup and should never contain business logic.
Summary
Sharing state between steps is essential for realistic Cucumber JVM scenarios because many workflows produce data that later steps need. A login step may produce a user session, an API step may produce a response, a checkout step may produce an order, and a verification step may need those values. Scenario-scoped shared state connects these steps cleanly.
The golden rules are straightforward: share state only within the same scenario, use Scenario Context instead of static variables, store only data needed by later steps, share business objects rather than scattered primitive values where appropriate, and keep Scenario Context as a data holder rather than a business logic class. With these rules, state sharing becomes reliable, readable, and parallel-friendly.