Skip to main content

Plain JUnit 5

How SpecBinder turns Gherkin scenarios into tests your Java tooling already understands

Gherkin is great for describing behavior.

But executing Gherkin often comes with a separate runtime world: custom runners, step discovery, glue code scanning, framework-specific reports, and debugging sessions that sometimes lead you through framework plumbing before you reach your own code.

SpecBinder takes a different route.

It translates your .feature or .specb files into ordinary JUnit 5 test code during compilation. Once generated, the scenarios run like any other JUnit test in your project.

No Cucumber runner. No runtime step discovery. No special execution universe.

Just JUnit 5.

Delightfully boring. Which, for test infrastructure, is usually the dream.

How SpecBinder changes the execution model

SpecBinder does not execute Gherkin directly at runtime.

Instead, it uses the Gherkin file as source input and generates Java test code from it.

For example, this scenario:

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 become a generated JUnit 5 test method:


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

The generated method is not pretending to be a test.

It is a test.

JUnit sees it as a normal @Test method. Your IDE sees it as a normal test method. Maven, Gradle, CI servers, and test reports see it as normal JUnit 5.

That is the point.

SpecBinder keeps Gherkin as the readable source of behavior, but the executable form is plain Java code built on plain JUnit 5.

Scenarios become ordinary test methods

A Gherkin Scenario maps naturally to a JUnit test method.

The scenario title becomes the test display name:


@Test
@Order(1)
@DisplayName("Scenario: Update quantity updates subtotal")
public void scenario_1() {
// generated step calls
}

That means individual scenarios can be run from the same places you already run tests:

  • the gutter icon in your IDE
  • the JUnit test tree
  • Maven Surefire
  • Gradle test tasks
  • CI build jobs

There is no separate “BDD run mode” you need to mentally switch into.

You can still write behavior in Gherkin. But when it is time to execute, you are back in familiar JUnit territory.

Home sweet @Test.

Rules become nested JUnit structure

Gherkin Rule is useful because it gives scenarios a home.

It says: these examples belong to this business rule.

SpecBinder preserves that structure using JUnit 5 nested tests.

Feature: Online shopping cart

Rule: Free shipping applies to orders over €50

Scenario: Show free-shipping banner when threshold is met
Given my cart subtotal is "55.00"
When I view the cart
Then I see the "Free shipping" banner

can become:


@Nested
@Order(1)
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
@DisplayName("Rule: Free shipping applies to orders over €50")
public class Rule_1 {

@Test
@Order(1)
@DisplayName("Scenario: Show free-shipping banner when threshold is met")
public void scenario_1() {
/**
* Given my cart subtotal is "55.00"
*/
myCartSubtotalIs$p1(55.00);

/**
* When I view the cart
*/
iViewTheCart();

/**
* Then I see the "Free shipping" banner
*/
iSeeThe$p1Banner("Free shipping");
}
}

This matters because test structure is not hidden inside a runner.

It is represented using JUnit’s own model:

  • @Nested for grouping
  • @DisplayName for readable names
  • @Order for stable scenario ordering

So when you open the JUnit test tree, the shape still feels like the feature file.

Feature. Rule. Scenario.

Only now it is also Java.

Backgrounds become JUnit lifecycle methods

Gherkin Background is setup that runs before each scenario in its scope.

SpecBinder maps that to JUnit’s own lifecycle model.

A feature-level Background becomes a generated @BeforeEach method on the outer generated test class:

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 become:


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

A rule-level Background works the same way, but inside the generated @Nested class for that rule.

So a feature-level background runs before each scenario in the feature. A rule-level background runs before each scenario in that rule. If both exist, JUnit runs the outer feature @BeforeEach first, then the nested rule @BeforeEach, then the scenario test method.

Same JUnit annotation. Different generated container.

No separate “before scenario” hook owned by a BDD automation runner.

No Cucumber-specific @Before, @BeforeStep, @After, or @AfterStep hooks to line up next to JUnit lifecycle methods and then wonder which one runs when.

Just @BeforeEach, using the lifecycle model your JUnit tests already use.

Scenario Outlines become parameterized tests

Scenario Outlines are another place where plain JUnit 5 pays off.

A Gherkin outline like this:

Scenario Outline: Shipping message depends on subtotal
Given my cart subtotal is "<subtotal>"
When I view the cart
Then I see the "<message>" banner

Examples:
| subtotal | message |
| 55.00 | Free shipping |
| 20.00 | Shipping cost |

can be generated as a JUnit parameterized test:


@ParameterizedTest(
name = "Example {index}: [{arguments}]"
)
@CsvSource(
useHeadersInDisplayName = true,
delimiter = '|',
textBlock = """
subtotal | message
55.00 | Free shipping
20.00 | Shipping cost
"""
)
@DisplayName("Scenario Outline: Shipping message depends on subtotal")
public void scenario_1(Double subtotal, String message) {
/**
* Given my cart subtotal is "<subtotal>"
*/
myCartSubtotalIs$p1(subtotal);

/**
* When I view the cart
*/
iViewTheCart();

/**
* Then I see the "<message>" banner
*/
iSeeThe$p1Banner(message);
}

Again, the important bit is not that SpecBinder invented a new execution mechanism.

It did the opposite.

It mapped the Gherkin concept onto the JUnit 5 concept developers already know: @ParameterizedTest with data from the Examples table.

So your scenario outline appears as a parameterized JUnit test instead of a special case inside a special runner.

Less magic. More Java.

Tags become JUnit tags

Gherkin tags are often used for filtering:

@checkout
@smoke
Scenario: Customer can complete checkout
Given my cart contains "Coffee Beans 1kg"
When I complete checkout
Then my order is placed

SpecBinder can carry those tags into the generated JUnit code:


@Test
@Tags({
@Tag("checkout"),
@Tag("smoke")
})
@DisplayName("Scenario: Customer can complete checkout")
public void scenario_1() {
// generated step calls
}

That means filtering can use the JUnit Platform mechanisms your build already understands.

You do not need a separate tag expression language just to run a subset of generated tests. You can use the same JUnit tag filtering you would use for handwritten tests.

This is one of those small things that becomes very nice over time.

Small things are where test suites either stay pleasant or slowly become furniture you trip over.

Debugging feels like Java debugging

One of the nicest effects of plain JUnit generation is debugging.

With a runtime BDD automation framework, the call path often goes through the framework first:

runner → feature parser → step matcher → step invocation → your code

That is not wrong.

But when something fails, it can make the execution feel indirect. You are not always looking at a straightforward Java call chain. You are looking at your code through the framework’s machinery.

With SpecBinder, the generated scenario method calls your step methods directly:


@Test
@DisplayName("Scenario: Update quantity updates subtotal")
public void scenario_1() {
myCartContains$p1WithQuantity$p2AndUnitPrice$p3(
"Wireless Headphones",
1,
60.00
);

iChangeTheQuantityTo$p1(2);

myCartSubtotalIs$p1(120.00);
}

And your step method is just Java:

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

You can put a breakpoint in the generated scenario method.

You can put a breakpoint in the step method.

You can step into application code.

You can inspect fields on the test instance.

You can use the debugger the same way you would use it for any other JUnit test.

Revolutionary? No.

That is exactly why it is useful.

Stack traces point where developers expect

When an assertion fails, you want the stack trace to help you fix the problem.

Not perform an archaeological dig through framework internals.

With generated JUnit tests, failures flow through ordinary Java method calls.

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

If this assertion fails, the important location is your assertion.

The generated scenario method is still useful because it shows which Gherkin step called the assertion. The comment above each generated call keeps the original step text nearby:

/**
* Then my cart subtotal is "120.00"
*/
myCartSubtotalIs$p1(120.00);

So you get both views:

  • the readable Gherkin step that explains intent
  • the Java method call and assertion that actually failed

That is a comfortable place to debug from.

Comfortable debugging is underrated.

Tooling works because the output is just JUnit

SpecBinder does not need to replace your Java testing stack.

It plugs into it.

Because the output is JUnit 5 code, the generated tests fit into the same workflow as your handwritten tests:

  • IDE test runners can discover and run them
  • CI can execute them through normal build tasks
  • reports can consume them as JUnit results
  • @Tag filtering works through the JUnit Platform

Navigation becomes more concrete too.

If a generated scenario calls myCartSubtotalIs$p1(120.00), that call is a real Java method reference. Your IDE can understand it as code.

Run Find Usages on myCartSubtotalIs$p1, and you can see every generated scenario method that calls it, alongside any handwritten Java references.

No guessing through step patterns. No plain-text search pretending to be navigation.

Just Java references, understood by the IDE and checked by the compiler.

This is especially useful for teams that already have Java testing infrastructure.

You do not need to ask, “How do we make our CI understand this BDD automation runner?”

You mostly ask, “Can our existing JUnit setup see generated test sources?”

That is a much smaller question.

And smaller questions are easier to answer before coffee.

You still write real test code

Plain JUnit 5 does not mean SpecBinder writes your test logic for you.

The generated scenario method defines the execution skeleton. Your implementation still decides what the step means.

public abstract class CartFeature {

private Cart cart;

public void myCartContains$p1WithQuantity$p2AndUnitPrice$p3(
String itemName,
Integer quantity,
Double unitPrice
) {
cart = new Cart();
cart.add(itemName, quantity, BigDecimal.valueOf(unitPrice));
}

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

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

But the important part is that this is normal Java test design. You can extract helper methods, use fixtures, call application services, use test doubles, or integrate with Spring, Quarkus, Micronaut, Testcontainers, or whatever your project already uses.

SpecBinder does not try to own that layer.

JUnit runs the test. Your Java code performs the behavior and assertions. SpecBinder provides the generated bridge from Gherkin to JUnit.

That boundary is intentionally small.

What this does not mean

Plain JUnit 5 does not mean there is no generated code.

There is generated code. That is the whole trick.

It also does not mean every developer will want to read generated scenario classes all day. Generated code is useful, but it is still generated code. It tends to be precise, repetitive, and not especially interested in winning poetry awards.

Fair enough.

The goal is not for generated code to replace the feature file as the thing humans read first.

The goal is for generated code to give Java tooling something concrete to compile, run, debug, and report.

There are also practical trade-offs:

  • your build must be configured so generated test sources are compiled and visible
  • annotation processing needs to run correctly in your build and IDE
  • generated method names are mechanical because they must be derived from step text
  • some IDEs may need a rebuild before generated tests appear cleanly
  • you still need good assertions and meaningful examples

SpecBinder makes Gherkin execution feel more like Java testing.

It does not remove the need to design good tests.

Sadly, no library has fully automated taste yet.

Why it matters

The biggest benefit of plain JUnit 5 is not novelty.

It is familiarity.

Your team already knows how to run JUnit tests. Your IDE already knows how to debug them. Your CI already knows how to report them. Your build already knows how to filter them. Your developers already know what a failing assertion looks like.

SpecBinder uses that existing path instead of building a parallel one.

That makes Gherkin feel less like a separate automation island and more like part of your normal Java test suite.

You still get readable behavior specifications.

But when those specifications run, they run as ordinary JUnit 5 tests.

For teams that like Gherkin’s clarity but want Java’s tooling ergonomics, that is the whole point.

Plain JUnit 5 is not a flashy feature.

It is the feature that lets everything else feel boring in the best possible way.