Skip to main content

Feature-scoped steps

How SpecBinder keeps step implementations close to the feature that uses them

Gherkin steps look simple.

That is part of the appeal.

Then my cart subtotal is "120.00"

But in many runtime BDD automation frameworks, that innocent-looking line is resolved against a shared pool of step definitions. The runner searches through available glue code, finds the matching pattern, converts the arguments, and invokes the method.

That can work well for small suites.

Then the suite grows.

The shared step-definition package becomes a little city. Streets, shortcuts, duplicate names, mysterious alleys. Everyone knows the important places. Nobody is completely sure what happens if you rename one.

SpecBinder takes a different route.

Steps are scoped through Java classes. A feature produces its own generated scenario class. That generated class calls step methods through normal Java method calls. If you want to share steps, you share them deliberately through the class hierarchy.

No global step registry.

No runtime glue search.

Just Java scoping doing Java scoping things.

Not exotic. Just explicit.

The problem with global step discovery

A global step pool sounds useful at first.

You define a step once, then any feature can use it. Nice. DRY. Efficient.

Until “any feature can use it” becomes the problem.

In larger suites, global step discovery can create a few familiar headaches:

  • Ambiguous matches — two step definitions accidentally match the same text, and you discover the conflict when the scenario runs.
  • Hidden coupling — one step implementation is reused across unrelated features, so changing it for checkout quietly affects shipping, billing, or account management.
  • Weak ownership — it becomes unclear which team or feature “owns” a shared step definition.
  • Poor discoverability — “Do we already have a step for this?” turns into searching annotations, regexes, feature files, and vibes.
  • Semantic drift — the step text says one thing, but the glue code behind it slowly comes to do something more, different, or less obvious.

The promise is reuse.

The cost is that the step-definition registry becomes a shared mutable resource.

And shared mutable resources have a long and distinguished history of ruining afternoons.

What SpecBinder does instead

SpecBinder does not bind steps by scanning a global registry at runtime.

It parses the .feature or .specb file during compilation and generates Java code for that feature. The generated scenario methods call generated or inherited step methods directly.

For example, this feature:

Feature: Online shopping cart

Scenario: Update quantity updates subtotal
Given my cart contains "Wireless Headphones" with quantity "1" and unit price "60.00"
When I change the quantity to "2"
Then my cart subtotal is "120.00"

can produce a generated feature-specific scenario class:

public abstract class CartFeatureScenarios extends CartFeature {

public abstract void myCartContains$p1WithQuantity$p2AndUnitPrice$p3(
String p1,
Integer p2,
Double p3
);

public abstract void iChangeTheQuantityTo$p1(Integer p1);

public abstract void myCartSubtotalIs$p1(Double p1);

@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);
}
}

Then your implementation class completes that feature:

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 important part is the shape.

The generated scenario class belongs to the feature. The step declarations belong to that generated class. The concrete implementation extends that generated class.

So the binding is not “some Gherkin text matched some pattern somewhere in the glue classpath.”

It is this:

cart.feature → CartFeatureScenarios → CartFeatureTest

A small, explicit Java hierarchy.

Much easier to reason about.

A step has a home

With global step discovery, a step definition can feel detached from the feature that uses it.

It lives somewhere in stepdefs. Maybe CartSteps. Maybe CommonSteps. Maybe SharedSteps2, because naming is hard and SharedSteps was already taken.

With SpecBinder, a step has a clearer home.

If it belongs only to the cart feature, implement it in the cart feature test class:

public class CartFeatureTest extends CartFeatureScenarios {

@Override
public void myCartSubtotalIs$p1(Double expectedSubtotal) {
assertEquals(
BigDecimal.valueOf(expectedSubtotal),
cart.subtotal()
);
}
}

That method is now part of the cart feature’s implementation.

Not checkout.

Not shipping.

Not “whatever regex happens to match this sentence today.”

It belongs to the cart feature.

That may sound like a small distinction, but it changes how the test suite feels as it grows. You do not start with a shared global vocabulary and then hope everyone uses it consistently. You start with feature-local behavior and share only when sharing is actually useful.

That is a healthier default.

Sharing still works

Feature-scoped steps do not mean every feature must live on an island.

Reuse is still available.

It is just explicit Java reuse.

For example, several shopping-related features may share authentication and cart setup:

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 individual features can opt into that shared behavior:

@Gherkin2JUnit("specs/cart.feature")
public abstract class CartFeature extends BaseShoppingFeature {
// cart-specific base helpers can go here
}
@Gherkin2JUnit("specs/checkout.feature")
public abstract class CheckoutFeature extends BaseShoppingFeature {
// checkout-specific base helpers can go here
}

Now both generated feature classes can inherit the shared methods through their own feature base class.

That gives you a useful middle ground:

  • cart-specific steps stay with cart
  • checkout-specific steps stay with checkout
  • genuinely shared shopping steps live in BaseShoppingFeature

No global registry required.

No accidental cross-feature pickup.

If a step is shared, the class hierarchy says so.

Sharing becomes a design decision

This is the subtle part.

SpecBinder does not only make sharing possible. It makes sharing visible.

In a global registry model, sharing can happen accidentally. Two features use the same phrase, the same pattern matches both, and suddenly one implementation serves multiple meanings.

Sometimes that is correct.

Sometimes it is just convenient.

Convenient is where the tiny cracks start.

With class-based scoping, you have to decide where a shared step belongs. Should it be local to one feature? Should it move into a base class? Should there be a BaseShoppingFeature, a BaseCheckoutFeature, or no base class at all?

Those are normal design questions.

They can be reviewed in code review. They can be refactored with Java tooling. They can be owned by the same team that owns the feature.

The decision stops being a side effect of runtime discovery.

That is the point.

Find Usages becomes real navigation

Because generated scenario methods call Java methods, IDE navigation becomes much more concrete.

Suppose a generated scenario calls this step method:

myCartSubtotalIs$p1(120.00);

That is a real Java method call.

So when you run Find Usages on myCartSubtotalIs$p1, your IDE can show every generated scenario method that calls it, along with any handwritten Java references.

No guessing through regexes.

No plain-text search across .feature files pretending to be semantic navigation.

Real references, found by the compiler and understood by the IDE.

This is especially useful when you are changing a shared base step. You can see which generated scenarios call it before you touch the implementation.

That changes the feel of refactoring.

You are not asking, “What text might this regex match?”

You are asking, “Who calls this method?”

Java tooling is very good at answering that question.

Refactoring is scoped too

Feature-scoped steps also make refactoring less mysterious.

If you change a step phrase in the feature file, the generated method name changes. If the implementation class still contains the old method, the compiler tells you that the new abstract method is not implemented.

For example, this step:

Then my cart subtotal is "120.00"

may generate:

public abstract void myCartSubtotalIs$p1(Double p1);

If the wording changes:

Then the cart subtotal should be "120.00"

then the generated declaration changes too:

public abstract void theCartSubtotalShouldBe$p1(Double p1);

Your implementation must follow.

That is not runtime step discovery failing after the test starts.

That is Java compilation telling you the feature and the implementation are no longer aligned.

Much better timing.

The compiler is not always charming, but it is punctual.

Concrete mode follows the same idea

In Concrete generation mode, the shape is slightly different, but the scoping idea is the same.

Instead of generating abstract step declarations for you to implement, SpecBinder generates a concrete JUnit test class that extends your marker or base class and calls methods it inherits from that class hierarchy.

@Gherkin2JUnit("specs/cart.feature")
@Gherkin2JUnitOptions(shouldBeAbstract = false)
public abstract class CartFeature {

public void iChangeTheQuantityTo$p1(Integer newQuantity) {
cart.changeQuantity(newQuantity);
}
}

The generated test can then call the inherited step method directly:

public class CartFeatureTest extends CartFeature {

@Test
@DisplayName("Scenario: Update quantity updates subtotal")
public void scenario_1() {
/**
* When I change the quantity to "2"
*/
iChangeTheQuantityTo$p1(2);
}
}

Again, there is no global registry.

The generated test class first uses the methods available through its own Java hierarchy. If a matching method is inherited, the call is a normal Java call. If it is not inherited, SpecBinder generates a failing method stub so the missing implementation is visible immediately.

Scoping by class hierarchy.

Not by runtime scanning.

What this does not mean

Feature-scoped steps do not mean you should never share steps.

They mean sharing should be deliberate.

Some steps really are common vocabulary inside a domain. Authentication, common setup, reusable fixtures, and broad module-level behavior can belong in a base class. That is fine.

Feature-scoped steps also do not magically prevent bad abstractions. You can still create a giant BaseEverythingFeature and put half the company in it.

Please do not.

That is not feature scoping. That is a global registry with a different shape.

There are also practical trade-offs:

  • when you decide to share a step, you need to choose the right home for that shared method
  • shared base classes need to stay small and intentional
  • refactoring a widely shared base step still requires care

SpecBinder gives you better boundaries.

It does not remove the need for judgment.

Sadly, judgment remains stubbornly unautomated.

Why it matters

The larger a Gherkin suite becomes, the more important boundaries become.

A global step registry makes every step potentially available everywhere. That can feel flexible, but it also makes ownership, refactoring, and discovery harder over time.

SpecBinder keeps the relationship smaller:

feature file → generated scenario class → Java step methods

Steps belong to a feature-specific Java hierarchy. Shared steps move into base classes only when you choose to put them there. IDE navigation works because calls are real Java calls. Refactoring gets compiler feedback instead of runtime surprises.

That is the benefit of feature-scoped steps.

Not less reuse.

Better reuse, with clearer ownership.