Skip to main content

Compile-time safety

How SpecBinder turns Gherkin changes into compiler feedback instead of runtime surprises

Traditional Cucumber-style tooling usually connects Gherkin to code at runtime. A scenario is parsed, step text is matched against a shared set of step definitions, and only when the test runs do you find out whether the step can be found, whether the matching was ambiguous, or whether the data passed through the step can actually be converted into the type your code expects.

SpecBinder moves that feedback earlier.

It parses your .feature or .specb files during Java compilation and generates ordinary JUnit 5 test code. Each scenario becomes a Java test method. Each step becomes a strongly named Java method call. Quoted values, Scenario Outline values, Doc Strings, and Data Tables become real Java parameters in generated code.

The result is a tighter feedback loop: when the spec and the test implementation no longer agree, your project fails to compile.

The problem with runtime step binding

Runtime step binding can feel flexible at first. You can write a line like this:

Then the order status is "PAID"

…and somewhere else define a matching step implementation.

That flexibility comes with a cost. The connection between the text and the code is indirect. The compiler cannot fully help you because the important relationship is hidden behind runtime discovery and pattern matching.

As suites grow, that can lead to familiar problems:

  • a Gherkin step exists, but no matching step definition is found until the scenario runs
  • two step definitions accidentally match the same text
  • a small wording change silently changes which implementation is selected
  • data is passed as strings and converted later
  • enum values, numbers, booleans, and table fields fail only when the test executes
  • refactoring step text is not as safe as refactoring ordinary Java code

SpecBinder is designed to make these problems visible earlier, through generated Java source code that your normal build already understands.

How SpecBinder changes the model

With SpecBinder, the Gherkin file is not interpreted by a separate runner at execution time. It is translated into Java during compilation.

For example, this step:

Given my cart contains "Wireless Headphones" with quantity "1" and unit price "60.00"

can generate both an abstract method declaration for the step and Java method calls to that declaration from the generated scenario test:

// Generated step method declaration
public abstract void myCartContains$p1WithQuantity$p2AndUnitPrice$p3(
String p1,
Integer p2,
Double p3
);

// Generated scenario method
@Test
@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
);
}

The generated declaration and calls are plain Java. Your concrete test class does not register a regex or rely on runtime step discovery. It implements the abstract step methods declared by the generated class, using normal Java overriding:

public class CartTest extends GeneratedCartScenarios {

@Override
public void myCartContains$p1WithQuantity$p2AndUnitPrice$p3(
String itemName,
Integer quantity,
Double unitPrice
) {
// TODO add real test implementation
}
}

This is where the compiler becomes useful. A concrete test class cannot compile until every generated abstract step method has been implemented with the exact generated signature. If the spec changes the step text or inferred parameter types, the generated abstract method changes too, and your implementation must be brought back into alignment.

This is the core of SpecBinder’s compile-time safety: the Gherkin file participates in the same type-checking workflow as the rest of your Java test code.

Concrete mode uses inherited step methods

The examples above show the abstract-class workflow: SpecBinder generates abstract step method declarations, and your concrete test class overrides them.

Concrete generation mode works differently.

In Concrete mode, the generated test class extends your marker or base class and uses any step methods it can inherit from that class hierarchy. If a matching method already exists, SpecBinder does not need to generate a failing stub for that step. The generated scenario method simply calls the inherited method.

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

public void iChangeTheQuantityTo$p1(Integer newQuantity) {
// TODO add real test implementation
}
}

For a step like this:

When I change the quantity to "2"

SpecBinder can generate a concrete JUnit test class that extends CartFeature and calls the inherited method:

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

This matters for the next section because the inherited method signature gives SpecBinder more information. If your marker/base class declares Integer, Boolean, DayOfWeek, or another target type, SpecBinder tries to render the textual Gherkin value as that declared type.

Parameter type mismatches are caught early

SpecBinder can infer step argument values from the method signatures you already implemented in the marker or base class. The generated test class inherits those methods, inspects their declared parameter types, and then tries to render the textual Gherkin values as those target Java types.

For quoted step arguments, values such as these are translated into typed Java arguments:

Given the quantity is "2"
And the price is "19.99"
And the product is active "true"

The generated calls can use types such as Integer, Double, and Boolean instead of treating everything as an opaque string.

/**
* Given the quantity is "2"
*/
theQuantityIs$p1(2);

/**
* And the price is "19.99"
*/
thePriceIs$p1(19.99);

/**
* And the product is active "true"
*/
theProductIsActive$p1(true);

They can also use your own custom enum types.

For example, your marker/base class can provide an inherited step method that expects a DayOfWeek:

// Inherited by the generated test class
public void theDeliveryDayIs$p1(DayOfWeek deliveryDay) {
// TODO add real test implementation
}

Then this Gherkin step:

Given the delivery day is "MONDAY"

can be rendered as a generated call site that passes the enum constant directly:

/**
* Given the delivery day is "MONDAY"
*/
theDeliveryDayIs$p1(DayOfWeek.MONDAY);

That matters because type mistakes become ordinary Java mistakes. If the Gherkin value cannot be rendered as the declared target type, the generated Java code does not compile.

For example, this Gherkin step contains a value that is not a valid DayOfWeek enum constant:

Given the delivery day is "FUNDAY"

SpecBinder would look for a matching DayOfWeek enum constant. Because none exists, it would fall back to supplying the textual value as-is:

/**
* Given the delivery day is "FUNDAY"
*/
theDeliveryDayIs$p1("FUNDAY");

The compiler catches this because string value FUNDAY is not valid a member of DayOfWeek enum type.

The same principle applies to Scenario Outlines. Example columns become parameters on a generated @ParameterizedTest, and the generated test body passes those typed parameters into your step methods. If an Examples value cannot be rendered as the target parameter type expected by the inherited step method, the generated call site becomes invalid Java, so the compiler catches the mismatch before the scenario runs.

Data Tables follow the same safety model

Data Tables are another place where runtime Gherkin automation often becomes string-heavy.

In many test suites, table rows are passed around as maps:

@Given("my cart contains the following items")
public void myCartContainsTheFollowingItems(List<Map<String, String>> rows) {
for (Map<String, String> row : rows) {
String name = row.get("name");
Integer quantity = Integer.valueOf(row.get("quantity"));
BigDecimal price = new BigDecimal(row.get("price"));

// TODO add item to cart
}
}

That works, but the structure is mostly enforced by convention.

Column names are string keys. Cell values are parsed manually. A renamed column, a misspelled key, or a value that no longer matches the expected type may not fail until the relevant scenario is executed.

SpecBinder applies the same compile-time model to Data Tables that it applies to ordinary step parameters.

A table can become a list of generated row objects:

public void myCartContainsTheFollowingItems(List<ItemsParam> items) {
ItemsParam item = items.getFirst();

String name = item.name();
Integer quantity = item.quantity();
BigDecimal price = item.price();
}

The generated parameter class gives the table a Java shape, with typed accessors for the columns.

If you refine that parameter class in your marker or base class, table values can be rendered as domain types such as enums or value objects. That means an unsupported table value can become invalid generated Java instead of a late parsing failure.

The detailed version of this idea is covered in the dedicated Type-safe data tables article.

Refactoring becomes more Java-like

Compile-time generation also makes Gherkin changes easier to reason about.

When a step phrase changes, the generated method name changes. If the old implementation no longer matches the generated call, the compiler points to the break. That is very different from discovering the mismatch through a runtime “undefined step” failure.

It also means normal Java tooling becomes more useful:

  • generated scenario methods call visible Java methods
  • method signatures are explicit
  • parameter types are visible in code
  • compilation tells you when the spec and implementation disagree
  • IDE navigation can work with generated Java like ordinary test code

SpecBinder does not try to make Gherkin into Java. The spec remains readable Gherkin. But once compiled, the relationship between the spec and the implementation is represented as Java source code rather than runtime glue.

What this does not mean

Compile-time safety does not prove that your business rules are correct. It does not replace thoughtful examples, good assertions, or domain review.

It also does not make every test failure a compiler error. Assertions still fail at runtime, as they should. If the system calculates the wrong subtotal, the test should fail when it runs.

What SpecBinder catches earlier are structural mismatches between the spec and the test code:

  • missing step implementations
  • incompatible method signatures
  • wrong argument counts
  • incompatible inferred types
  • invalid enum refinements
  • generated Data Table types that no longer match the spec

In other words, compile-time safety protects the binding between the human-readable spec and the executable test code.

Why it matters

The larger a Gherkin test suite becomes, the more costly late feedback becomes. Runtime step discovery means some problems only appear after the runner starts executing scenarios. String-based step arguments and table rows move type problems even later.

SpecBinder shifts that feedback into the build.

That makes the workflow feel closer to normal Java development: change the spec, compile, see what no longer matches, fix it, then run the tests to check behavior.

For teams that like Gherkin’s readability but want stronger developer ergonomics, compile-time safety is the foundation of the SpecBinder approach.