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.featureDependencies, 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.3cucumber-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:
Featurenames the capability, followed by free text explaining the rule. Business readers should understand that text without the scenarios.Scenariois one concrete example.Scenario Outlineruns the same steps once per row ofExamples, 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
driverfield is never shared between scenarios, so they can't leak state into each other. @Beforeand@Afterare Cucumber hooks (fromio.cucumber.java, not JUnit).@Afterruns 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
Whensteps 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 testThe 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 SUCCESSThree 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 Outlinefor data variations, not copy-pasted scenarios. - Use
Backgroundonly 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 thecucumber.filter.tagsconfiguration 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.