How Cucumber-JVM binds a feature file to Java
Cucumber-JVM does not link scenarios to code by file name, directory, or any declaration you write. At startup it scans the packages named in its glue configuration, collects every method annotated with @Given, @When, @Then or @And, and builds a table of the expressions on those annotations. Each step in each scenario is then matched against that table at runtime.
Two consequences follow, and between them they account for most of the time people lose on their first Cucumber-JVM suite. A step definition can live in any class in any glue package and still be found — so splitting steps across files is free. And a step that matches two expressions is a hard failure (DuplicateStepDefinitionException), not a first-match-wins, so a broad expression written early will collide with a specific one written later.
The expressions themselves are Cucumber Expressions rather than regular expressions: {string}, {int}, {word} and {float} cover nearly everything, and the captured values arrive as typed method parameters. A regex still works if you anchor it with ^ and $, but you rarely need one.
Where the files go
Maven's conventions and Cucumber's classpath scan meet in a layout that is not negotiable in practice:
src
└── test
├── java
│ └── steps
│ ├── CustomerSignInSteps.java
│ ├── RunCucumberTest.java
│ └── World.java
└── resources
└── features
└── customer-sign-in.featureFeature files are resources. Put them in src/test/java and Maven will not copy them to the classpath, Cucumber will find nothing, and the run will pass with zero scenarios executed — a green build that tested nothing.
The JUnit 5 runner
The @RunWith(Cucumber.class) runner most tutorials still show is JUnit 4. On JUnit 5 the entry point is the cucumber-junit-platform-engine, selected from a suite class:
package steps; import org.junit.platform.suite.api.ConfigurationParameter;import org.junit.platform.suite.api.IncludeEngines;import org.junit.platform.suite.api.SelectClasspathResource;import org.junit.platform.suite.api.Suite; import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME;import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME; @Suite@IncludeEngines("cucumber")@SelectClasspathResource("features")@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "steps")@ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty, html:target/cucumber.html")public class RunCucumberTest {}@SelectClasspathResource("features") points at the resources directory above; GLUE_PROPERTY_NAME names the package to scan for step definitions. Both are paths on the classpath, not file paths, which is why neither starts with src/.
Dependencies
<dependency> <groupId>io.cucumber</groupId> <artifactId>cucumber-java</artifactId> <version>7.20.1</version> <scope>test</scope></dependency><dependency> <groupId>io.cucumber</groupId> <artifactId>cucumber-junit-platform-engine</artifactId> <version>7.20.1</version> <scope>test</scope></dependency><dependency> <groupId>io.cucumber</groupId> <artifactId>cucumber-picocontainer</artifactId> <version>7.20.1</version> <scope>test</scope></dependency>cucumber-picocontainer is optional but you will want it on the second day: it is what lets two step-definition classes share a WebDriver instance without a static field.
A worked example, end to end
The requirement below is the kind of thing that arrives in a ticket — prose, with one security constraint buried in the middle and one threshold that is easy to misread.
A registered customer signs in with their email and password. Correctcredentials take them to their dashboard. A wrong password shows an error anddoes not reveal whether the email exists. Five failed attempts in ten minuteslocks the account for an hour.Note what the generated feature file does with "five failed attempts in ten minutes locks the account". The boundary is at five, so the Examples: table tests four and five rather than three and ten — an off-by-one here is the difference between a lock that fires early and one that never fires.
Feature: Customer sign-in Background: Given a registered customer "[email protected]" with password "correct-horse" Scenario: Correct credentials reach the dashboard Given the customer is on the sign-in page When they sign in as "[email protected]" with password "correct-horse" Then they should land on the dashboard Scenario: A wrong password does not confirm the account exists Given the customer is on the sign-in page When they sign in as "[email protected]" with password "wrong" Then they should see the error "Email or password is incorrect" And the error should not mention whether the email is registered Scenario Outline: The account locks after five failures in ten minutes Given the customer has failed to sign in <attempts> times in the last 10 minutes When they sign in as "[email protected]" with password "wrong" Then the account lock state should be "<locked>" Examples: | attempts | locked | | 3 | open | | 4 | locked |package steps; import io.cucumber.java.en.Given;import io.cucumber.java.en.Then;import io.cucumber.java.en.When;import org.openqa.selenium.By;import org.openqa.selenium.WebDriver;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertFalse; public class CustomerSignInSteps { private final WebDriver driver; // PicoContainer constructs this class and hands it the same World instance // every other step class in the scenario receives. Do not new up a driver // here. public CustomerSignInSteps(World world) { this.driver = world.driver(); } @Given("the customer is on the sign-in page") public void theCustomerIsOnTheSignInPage() { driver.get("https://example.com/sign-in"); } @When("they sign in as {string} with password {string}") public void theySignInAs(String email, String password) { driver.findElement(By.id("email")).sendKeys(email); driver.findElement(By.id("password")).sendKeys(password); driver.findElement(By.cssSelector("button[type=submit]")).click(); } @Then("they should see the error {string}") public void theyShouldSeeTheError(String message) { assertEquals(message, driver.findElement(By.cssSelector("[role=alert]")).getText()); } @Then("the error should not mention whether the email is registered") public void theErrorShouldNotLeakAccountExistence() { String text = driver.findElement(By.cssSelector("[role=alert]")).getText().toLowerCase(); assertFalse(text.contains("no account")); assertFalse(text.contains("not registered")); }}Three things that will cost you an afternoon
What this tool does with a Java project
Gherkinizer takes the requirement, produces the feature file, and parses the result with the official Cucumber grammar before showing it to you — so a file that would not have loaded in your suite never reaches your clipboard. The export names files the way a Cucumber-JVM project expects them:
customer-sign-in.featureinsrc/test/resources/featuresCustomerSignInSteps.javainsrc/test/java/steps- a README naming
mvn testas the command that runs it
Java step definitions are generated on the free tier — this is the one language where you get the feature file and the glue code without an account upgrade.