Skip to content
Independent guides for QA & test automationRSSEditorial policy
QA Vibes

Cucumber BDD with Java: Gherkin, Step Definitions, and a Tested JUnit Platform Setup

A working Cucumber project from feature file to green build: Gherkin scenarios and a Scenario Outline, step definitions with hooks, a JUnit Platform suite runner, and the dependency mismatch that breaks it if you add JUnit 6.

QA Vibes EditorialPublished Updated 7 minTested with Cucumber 7.34.8, JUnit Platform 1.14.2, Selenium 4.49.0, Java 21, Maven 3.9.9Revision history ↓

Key takeaways

  • Cucumber is worth its extra layer only when non-developers read and help write the scenarios.
  • Current Cucumber runs on the JUnit Platform; the JUnit 4 runner is deprecated.
  • Cucumber 7.34.8 needs JUnit 5.14 and Platform 1.14; adding JUnit 6 jars fails with NoClassDefFoundError.
  • Write Gherkin about behavior, and keep clicks and selectors in the step definitions.
Contents (11 sections)

Introduction

Cucumber runs plain-language examples as tests. A product owner, a developer, and a tester agree on how a feature should behave, write it down as scenarios in Gherkin (Given, When, Then), and Cucumber connects each line to a piece of code.

The value is in that first part, the conversation. Cucumber's own documentation describes BDD as a way to close the gap between business and technical people, not as a testing tool. If nobody outside engineering will ever read the scenarios, plain JUnit or Playwright tests are simpler and just as good.

This tutorial builds a complete, working project and runs it: a login feature for The Internet, a public practice site, with Selenium driving Chrome. Everything below was run on Java 21 and Maven 3.9.9.

Project layout

cucumber-demo/
├── pom.xml
└── src/test/
    ├── java/
    │   ├── RunCucumberTest.java
    │   └── steps/LoginSteps.java
    └── resources/features/
        └── login.feature

Dependencies, and the JUnit version trap

Current Cucumber runs on the JUnit Platform. The old cucumber-junit module and its @RunWith(Cucumber.class) runner are for JUnit 4 and are deprecated. These are the dependencies we used:

<dependencies>
  <dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-java</artifactId>
    <version>7.34.8</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>io.cucumber</groupId>
    <artifactId>cucumber-junit-platform-engine</artifactId>
    <version>7.34.8</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.junit.platform</groupId>
    <artifactId>junit-platform-suite</artifactId>
    <version>1.14.2</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>5.14.2</version>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.seleniumhq.selenium</groupId>
    <artifactId>selenium-java</artifactId>
    <version>4.49.0</version>
    <scope>test</scope>
  </dependency>
</dependencies>

Notice the JUnit versions: 5.14.2 and 1.14.2, not the newer JUnit 6. Our first attempt used JUnit 6.1.3 for junit-jupiter and junit-platform-suite, and no test ran:

[ERROR] The wrapped NoClassDefFoundError is likely caused by the versions of JUnit jars on the classpath/module path not being properly aligned.
[ERROR] The following conflicting versions were detected:
[ERROR] - org.junit.jupiter.api: 6.1.3
[ERROR] - org.junit.platform.commons: 1.14.2
[ERROR] - org.junit.platform.engine: 1.14.2
[ERROR] - org.junit.platform.launcher: 6.1.3

cucumber-junit-platform-engine 7.34.8 brings in JUnit Platform 1.14.2, and every JUnit module on the classpath has to come from the same release line. Match your JUnit dependencies to the Platform version Cucumber uses, or import the JUnit BOM for that version. As with any Maven project, also pin a recent maven-surefire-plugin (we used 3.5.4).

Step 1: write the feature file

src/test/resources/features/login.feature:

Feature: Log in to the secure area
  Registered users log in with a username and password.
  A wrong password must never open the secure area.
 
  Scenario: Valid credentials open the secure area
    Given I am on the login page
    When I log in as "tomsmith" with the correct password
    Then I see the message "You logged into a secure area!"
    And the page heading is "Secure Area"
 
  Scenario Outline: Invalid credentials are rejected
    Given I am on the login page
    When I log in as "<username>" with the password "<password>"
    Then I see the message "<message>"
 
    Examples:
      | username | password       | message                   |
      | tomsmith | wrong-password | Your password is invalid! |
      | nobody   | anything       | Your username is invalid! |

How to read it:

  • Feature names the capability, followed by free text explaining the rule. Business readers should understand that text without the scenarios.
  • Scenario is one concrete example.
  • Scenario Outline runs the same steps once per row of Examples, replacing <username> and the other placeholders. The two rows cover a wrong password and an unknown user, which show different messages.
  • Quoted values become parameters in the step definitions.

The valid scenario says "the correct password" instead of writing the password into the file. Feature files are documentation; a password doesn't belong in them, even a public practice one.

Step 2: write the step definitions

Each Gherkin line needs a Java method whose annotation matches it. {string} is a Cucumber Expression that captures the quoted value. src/test/java/steps/LoginSteps.java:

package steps;
 
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
 
import io.cucumber.java.After;
import io.cucumber.java.Before;
import io.cucumber.java.en.Given;
import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import java.time.Duration;
import org.openqa.selenium.By;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
 
public class LoginSteps {
    private static final String PASSWORD =
        System.getenv().getOrDefault("INTERNET_PASSWORD", "SuperSecretPassword!");
 
    private WebDriver driver;
 
    @Before
    public void startBrowser() {
        ChromeOptions options = new ChromeOptions();
        options.addArguments("--headless=new");
        driver = new ChromeDriver(options);
    }
 
    @After
    public void stopBrowser() {
        driver.quit();
    }
 
    @Given("I am on the login page")
    public void iAmOnTheLoginPage() {
        driver.get("https://the-internet.herokuapp.com/login");
    }
 
    @When("I log in as {string} with the correct password")
    public void iLogInWithTheCorrectPassword(String username) {
        logIn(username, PASSWORD);
    }
 
    @When("I log in as {string} with the password {string}")
    public void iLogInWithThePassword(String username, String password) {
        logIn(username, password);
    }
 
    @Then("I see the message {string}")
    public void iSeeTheMessage(String message) {
        String flash = new WebDriverWait(driver, Duration.ofSeconds(10))
            .until(ExpectedConditions.visibilityOfElementLocated(By.id("flash")))
            .getText();
        assertTrue(flash.contains(message), () -> "Flash message was: " + flash);
    }
 
    @Then("the page heading is {string}")
    public void thePageHeadingIs(String heading) {
        assertEquals(heading, driver.findElement(By.tagName("h2")).getText());
    }
 
    private void logIn(String username, String password) {
        driver.findElement(By.id("username")).sendKeys(username);
        driver.findElement(By.id("password")).sendKeys(password);
        driver.findElement(By.cssSelector("button[type='submit']")).click();
    }
}

Things to notice:

  • Cucumber creates a new instance of the step class for every scenario. The driver field is never shared between scenarios, so they can't leak state into each other.
  • @Before and @After are Cucumber hooks (from io.cucumber.java, not JUnit). @After runs even when a step fails, so Chrome is always closed.
  • The assertion message includes the actual text. When the step fails, the report shows what the page said, not only that it didn't match.
  • Both When steps reuse one private method. Step definitions should be thin; logic that grows goes into helper or page object classes.

Step 3: add the runner

src/test/java/RunCucumberTest.java tells the JUnit Platform to run Cucumber on everything in features:

import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME;
import static io.cucumber.junit.platform.engine.Constants.PLUGIN_PROPERTY_NAME;
 
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;
 
@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 {}

GLUE_PROPERTY_NAME is the package containing step definitions. The pretty plugin prints each step to the console, and html:target/cucumber.html writes a report you can open in a browser or attach to a CI build.

Step 4: run it

mvn test

The pretty plugin prints every scenario and step with the method that implements it, for example:

Scenario Outline: Invalid credentials are rejected                 # classpath:features/login.feature:18
  Given I am on the login page                                     # steps.LoginSteps.iAmOnTheLoginPage()
  When I log in as "tomsmith" with the password "wrong-password"   # steps.LoginSteps.iLogInWithThePassword(java.lang.String,java.lang.String)
  Then I see the message "Your password is invalid!"               # steps.LoginSteps.iSeeTheMessage(java.lang.String)

and Maven ends with:

[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 33.90 s -- in RunCucumberTest
[INFO] BUILD SUCCESS

Three tests: one Scenario plus two rows of the Scenario Outline. Each starts its own headless Chrome, which accounts for most of the time.

If a Gherkin line has no matching step definition, Cucumber reports the step as undefined and prints a code snippet you can paste into the step class as a starting point.

Writing scenarios people want to read

Compare two ways of writing the same scenario:

Scenario: Login (imperative, avoid)
  Given I open "https://the-internet.herokuapp.com/login"
  When I type "tomsmith" into the field with id "username"
  And I type the password into the field with id "password"
  And I click the button with type "submit"
  Then the element with id "flash" contains "You logged into a secure area!"
Scenario: Valid credentials open the secure area
  Given I am on the login page
  When I log in as "tomsmith" with the correct password
  Then I see the message "You logged into a secure area!"

The first describes clicks and element IDs. A product owner can't review it, and it breaks when the markup changes. The second describes behavior: it stays true when the page is redesigned, and the details live in step definitions where developers maintain them.

Guidelines that keep feature files useful:

  • Write in business language. No URLs, CSS selectors, or element IDs.
  • One behavior per scenario. If a scenario needs "And then … And then …", it's probably two.
  • Keep scenarios short. Three to six steps is typical.
  • Use Scenario Outline for data variations, not copy-pasted scenarios.
  • Use Background only for steps every scenario in the file shares, and keep it to one or two lines.
  • Tag scenarios (@smoke, @slow) and select them at run time with the cucumber.filter.tags configuration parameter.

Troubleshooting

NoClassDefFoundError with a list of conflicting JUnit versions. Your JUnit dependencies don't match the Platform version Cucumber brings in. See the dependency section above.

Scenarios are found but every step is undefined. The glue package is wrong. GLUE_PROPERTY_NAME must match the package of your step classes (steps here).

Tests run: 0. Maven didn't find the runner. The class name should end in Test so Surefire includes it, and the feature files must be under src/test/resources in the folder named by @SelectClasspathResource.

Unable to establish loopback connection on Windows. A JDK networking issue on some Windows machines, not a Cucumber or Selenium bug. It hit every scenario on our machine before Chrome opened; see the fix in our Selenium getting-started guide.

Conclusion

Cucumber pays off when scenarios are written with the people who own the behavior and read by them afterward. The mechanics are small: a feature file, thin step definitions with hooks, a JUnit Platform suite runner, and JUnit dependencies that match what Cucumber expects. Start with one feature the team actually discusses, keep the Gherkin declarative, and let the step definitions carry the technical detail.

Sources and further reading

Tools mentioned

CucumberUI AutomationOpen source
SeleniumUI AutomationOpen source
IntelliJ IDEADeveloper ToolsFree plan
JiraIssue TrackingFree plan

Links go to each tool’s official site. How we choose and link tools

Revision history

Updated source links that had moved: the pages still exist, at new addresses.
Rewritten as a runnable Cucumber 7.34.8 project on the JUnit Platform, replacing the deprecated JUnit 4 runner, and tested against The Internet practice site.
Revised during a site-wide content audit.
First published.

Spotted a mistake? Report it — corrections land here.

Written and reviewed by

QA Vibes Editorial

Articles are written and reviewed by practicing QA and automation engineers. Every article lists its sources and shows when it was last updated.

Try it on a real story

Acceptance criteria grader

Paste a user story, see which criteria a tester could misread, and export them as Gherkin.