Simple state management
How SpecBinder lets Gherkin steps share state using ordinary Java fields
Sharing state between Gherkin steps is one of those problems that starts small.
A Given step creates a cart. A When step adds an item. A Then step checks the subtotal.
Simple.
Until the implementation needs to pass that state from one step to another.
That state might be a cart, a customer, an API response, a page object, a calculated value, or a captured exception.
The real question is not whether state exists.
It does.
The question is how much framework machinery you need just to share it.
In many Gherkin automation setups, the answer often becomes some form of shared scenario context, dependency injection, or framework lifecycle wiring.
SpecBinder takes a smaller route.
Your steps are Java methods on the same test object. So the state can be Java fields on that object.
That is it.
Simple, explicit, and easy to understand.
Why runtime Gherkin automation often needs shared context
In many Cucumber-style runtimes, step definitions can live across many classes.
A single scenario may call one step from CartSteps, another from CheckoutSteps, another from CustomerSteps, and
another from CommonSteps.
The runner discovers those step definitions at runtime, creates the glue objects, wires dependencies if configured, and invokes the matching methods.
That model is flexible.
But because the steps may live on different objects, sharing state becomes a framework concern.
So the framework needs some way to make those separate objects see the same scenario data.
Common answers include:
- dependency injection
- scenario-scoped beans
- a custom “world” object
- a shared context class
- maps keyed by strings
- framework-specific hooks that prepare or clean up state
All of this can work.
It is also more machinery than many scenarios actually need.
And over time, the shared context can become its own small ecosystem. A cart here. A customer there. A
lastResponse, currentUser, createdOrder, errorMessage, temporarySomething, and, naturally, misc.
Nature finds a way.
The usual symptoms
The problem is not that dependency injection exists.
Dependency injection is useful when you are sharing real dependencies: services, repositories, drivers, application contexts, and external resources.
The problem is needing dependency injection just to pass test state from Given to Then.
That tends to create a few familiar symptoms.
Newcomers have to learn the wiring first.
Reading a step definition is not enough. You also need to know how the runner creates step classes, how scenario scope works, where the context object comes from, and which hooks prepare it.
Context objects become grab bags.
A shared ScenarioContext starts clean, then slowly collects every value any scenario ever needed. Nothing feels
important enough to remove. So nothing leaves.
Coupling becomes accidental.
A step from one area can read state set by a step from another area because both happen to share the same context object.
That is convenient.
Convenient is where tiny cracks often start.
Lifecycle becomes harder to reason about.
You may need to understand runner-managed object creation, scenario scope, @Before, @After, @BeforeStep,
@AfterStep, tag-specific hooks, and how all of that interacts with JUnit lifecycle methods.
At some point, the test setup has more plot than the feature.
What SpecBinder changes
SpecBinder does not execute Gherkin by scanning a global pool of step definitions at runtime.
It parses the .feature or .specb file during Java compilation and generates JUnit 5 test code. The generated
scenario method calls step methods through normal Java calls.
For the cart scenario, the generated code can look like this:
@Test
@Order(1)
@DisplayName("Scenario: Update quantity updates subtotal")
public void scenario_1() {
/**
* Given my cart contains "Wireless Headphones" with quantity "1" and unit price "60.00"
*/
myCartContains$p1WithQuantity$p2AndUnitPrice$p3(
"Wireless Headphones",
1,
60.00
);
/**
* When I change the quantity to "2"
*/
iChangeTheQuantityTo$p1(2);
/**
* Then my cart subtotal is "120.00"
*/
myCartSubtotalIs$p1(120.00);
}
There is no runtime step lookup here.
The scenario is a JUnit test method. The steps are method calls. The implementation lives in your Java test class hierarchy.
That means state sharing can use the same mechanism you already use in ordinary JUnit tests:
fields.
State is just fields
Here is a complete step implementation shape:
public class CartFeatureTest extends CartFeatureScenarios {
private Cart cart;
@Override
public void myCartContains$p1WithQuantity$p2AndUnitPrice$p3(
String itemName,
Integer quantity,
Double unitPrice
) {
cart = new Cart();
cart.add(
itemName,
quantity,
BigDecimal.valueOf(unitPrice)
);
}
@Override
public void iChangeTheQuantityTo$p1(Integer newQuantity) {
cart.changeQuantity(newQuantity);
}
@Override
public void myCartSubtotalIs$p1(Double expectedSubtotal) {
assertEquals(
BigDecimal.valueOf(expectedSubtotal),
cart.subtotal()
);
}
}
The Given step assigns cart.
The When step mutates cart.
The Then step asserts against cart.
No special context object. No string keys. No scenario scope annotation. No separate dependency injection setup just to remember the cart.
It is the same pattern you would use in a handwritten JUnit test.
class CartTest {
private Cart cart;
@BeforeEach
void setUp() {
cart = new Cart();
}
@Test
void updateQuantityUpdatesSubtotal() {
cart.add("Wireless Headphones", 1, BigDecimal.valueOf(60.00));
cart.changeQuantity(2);
assertEquals(
BigDecimal.valueOf(120.00),
cart.subtotal()
);
}
}
SpecBinder does not make state special.
It lets it stay boring.
For test state, boring is excellent.
One scenario, one test instance by default
The default JUnit 5 lifecycle creates a new test instance for each test method.
Since SpecBinder generates each scenario as a JUnit test method, each scenario gets fresh instance fields by default.
That means this field:
private Cart cart;
starts clean for each scenario.
One scenario can create a cart, mutate it, and assert against it. The next scenario starts with a new test instance, so it does not inherit leftover state from the previous scenario.
That is exactly what you usually want.
No manual reset.
No context cleanup.
No wondering whether yesterday’s scenario has quietly left crumbs in today’s test.
Crumbs belong in cookies, not test state.
Background steps use the same fields
Gherkin Background is setup that runs before each scenario in its scope.
SpecBinder maps that setup to JUnit lifecycle code.
For example:
Feature: Online shopping cart
Background:
Given I am a registered customer
And my cart is empty
Scenario: Add item to cart
When I add "Coffee Beans 1kg" to the cart
Then my cart contains "Coffee Beans 1kg"
can generate a @BeforeEach method that calls the same step methods used by scenarios:
@BeforeEach
public void background() {
/**
* Given I am a registered customer
*/
iAmARegisteredCustomer();
/**
* And my cart is empty
*/
myCartIsEmpty();
}
@Test
@Order(1)
@DisplayName("Scenario: Add item to cart")
public void scenario_1() {
/**
* When I add "Coffee Beans 1kg" to the cart
*/
iAdd$p1ToTheCart("Coffee Beans 1kg");
/**
* Then my cart contains "Coffee Beans 1kg"
*/
myCartContains$p1("Coffee Beans 1kg");
}
Your implementation can keep using fields:
public class CartFeatureTest extends CartFeatureScenarios {
private Customer customer;
private Cart cart;
@Override
public void iAmARegisteredCustomer() {
customer = customerRepository.createRegisteredCustomer();
}
@Override
public void myCartIsEmpty() {
cart = new Cart(customer);
}
@Override
public void iAdd$p1ToTheCart(String itemName) {
cart.add(itemName);
}
@Override
public void myCartContains$p1(String expectedItemName) {
assertTrue(cart.contains(expectedItemName));
}
}
The background sets customer and cart.
The scenario uses them.
Again, nothing new is required. The setup is just JUnit lifecycle code calling Java methods on the same test object.
Rule backgrounds work the same way
Gherkin Rule blocks give scenarios a more specific home.
SpecBinder represents rules using JUnit nested structure. A rule-level Background becomes setup inside that generated
nested rule class.
For example:
Feature: Online shopping cart
Rule: Free shipping applies when subtotal is at least €50
Background:
Given my cart subtotal is "45.00"
Scenario: Adding a €10 item enables free shipping
When I add an item priced "10.00"
Then I see the "Free shipping" banner
The generated rule structure can call the same step methods:
@Nested
@Order(1)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@DisplayName("Rule: Free shipping applies when subtotal is at least €50")
public class Rule_1 {
@BeforeEach
public void background() {
/**
* Given my cart subtotal is "45.00"
*/
myCartSubtotalIs$p1(45.00);
}
@Test
@Order(1)
@DisplayName("Scenario: Adding a €10 item enables free shipping")
public void scenario_1() {
/**
* When I add an item priced "10.00"
*/
iAddAnItemPriced$p1(10.00);
/**
* Then I see the "Free shipping" banner
*/
iSeeThe$p1Banner("Free shipping");
}
}
The implementation can still be field-based:
public class CartFeatureTest extends CartFeatureScenarios {
private Cart cart;
private List<Banner> visibleBanners;
@Override
public void myCartSubtotalIs$p1(Double subtotal) {
cart = Cart.withSubtotal(BigDecimal.valueOf(subtotal));
}
@Override
public void iAddAnItemPriced$p1(Double price) {
cart.add("Test item", 1, BigDecimal.valueOf(price));
visibleBanners = bannerRules.evaluate(cart);
}
@Override
public void iSeeThe$p1Banner(String expectedText) {
assertTrue(
visibleBanners.stream()
.anyMatch(banner -> banner.text().equals(expectedText))
);
}
}
The rule background prepares cart.
The scenario mutates it and checks the resulting banner.
Still fields.
Still JUnit.
Still no ScenarioContext doing interpretive dance in the corner.
Dependencies are different from state
There is an important distinction here.
Test state is data produced during a scenario: the cart, the response, the calculated subtotal, the captured exception.
Dependencies are things your test needs in order to run: services, repositories, drivers, application contexts, temporary directories, database containers, and so on.
SpecBinder makes simple test state simple.
It does not try to replace your dependency story.
Since the generated tests are plain JUnit 5, you can use the same mechanisms you would use in ordinary Java tests:
@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = ShopApplication.class)
public abstract class CartFeature {
@Autowired
private CartService cartService;
@Autowired
private PricingService pricingService;
protected Cart cart;
public void myCartIsEmpty() {
cart = cartService.createEmptyCart();
}
public void iAdd$p1ToTheCart(String itemName) {
cartService.addItem(cart.id(), itemName);
cart = cartService.findById(cart.id());
}
}
Use Spring when you need Spring.
Use JUnit extensions when you need JUnit extensions.
Use Testcontainers when you need real infrastructure.
But do not introduce a dependency injection container only because step one needs to pass a value to step three.
That job belongs to a field.
A humble field. Doing honest work.
Shared base classes still work
This state model also fits the feature-scoped step model.
Feature-specific state can live in the feature test class:
public class CartFeatureTest extends CartFeatureScenarios {
private Cart cart;
// cart-specific step implementations
}
Genuinely shared state and helpers can live in a deliberate base class:
public abstract class BaseShoppingFeature {
protected Customer customer;
protected Cart cart;
public void iAmASignedInShopper$p1(String email) {
customer = customerRepository.findByEmail(email);
}
public void myCartIsEmpty() {
cart = new Cart(customer);
}
}
Then a feature opts into that shared behaviour through normal Java inheritance:
@Gherkin2JUnit("specs/cart.feature")
public abstract class CartFeature extends BaseShoppingFeature {
// cart-specific helpers can go here
}
That is still explicit.
If state is shared, the class hierarchy says so.
No global context object quietly making every feature aware of every other feature’s leftovers.
Teardown is just JUnit too
Sometimes fields need cleanup.
Maybe you created temporary files. Maybe you started a fake server. Maybe you inserted records into a database and want to clean them after each scenario.
Use JUnit lifecycle methods:
public class CartFeatureTest extends CartFeatureScenarios {
private TestServer server;
@BeforeEach
void startServer() {
server = TestServer.start();
}
@AfterEach
void stopServer() {
server.stop();
}
}
There is no SpecBinder-specific lifecycle API to learn for that.
The generated scenarios are JUnit tests, so JUnit lifecycle rules apply:
@BeforeEachprepares state before each scenario@AfterEachcleans it up after each scenario@BeforeAlland@AfterAllhandle class-level setup and teardown@TestInstance(TestInstance.Lifecycle.PER_CLASS)changes the test instance lifecycle when you deliberately want that- JUnit extensions handle cross-cutting setup
There is no separate SpecBinder lifecycle to memorize. These are just the JUnit tools you already use.
What this does not mean
Simple state management does not mean all state should be casual.
Fields are easy to use, which means they are also easy to overuse.
You can still make a mess if every scenario writes to ten fields with vague names like result, data, object, and
thing.
Please do not make thing responsible for checkout.
Good test design still matters:
- keep fields close to the feature that needs them
- prefer clear names like
cart,customer,lastResponse, orvisibleBanners - reset or recreate mutable objects through
Backgroundor@BeforeEach - share base state only when the sharing is genuinely useful
- use dependency injection for real dependencies, not simple scenario values
SpecBinder gives you a simpler state model.
It does not remove the need to design readable tests.
Good names still matter.
Why it matters
State sharing is one of those details that shapes how a Gherkin automation suite feels over time.
If every scenario needs a context object, a scenario scope, framework-specific hooks, and a wiki page, then the test suite starts to feel heavier than the behaviour it describes.
SpecBinder keeps the model smaller.
A feature becomes generated JUnit code. A scenario becomes a test method. Steps become Java method calls. State lives in fields on the test object.
That means the mental model is the same one Java developers already use:
setup state → call methods → assert result
No special state container required.
No separate Gherkin runner lifecycle to memorize.
No global bag of mystery values.
Just Gherkin on the outside, JUnit and Java on the inside.
For teams that like readable Gherkin but prefer ordinary Java test mechanics, that is the point.
Simple state management is not flashy.
It is better than flashy.
It is obvious once you see it.