01 · BDD with Cucumber¶
Every test so far has been written by and for programmers. Cucumber flips that: it lets a product owner, a tester, and a developer agree on behaviour in plain English before a line of automation exists, then runs that English as an executable test. The language is called Gherkin, and the glue code that turns it into Java is a step definition.
1. Setup¶
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-java</artifactId>
<version>7.15.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.cucumber</groupId>
<artifactId>cucumber-junit-platform-engine</artifactId>
<version>7.15.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-suite</artifactId>
<version>1.10.2</version>
<scope>test</scope>
</dependency>
2. A feature file¶
src/test/resources/features/login.feature:
Feature: Login
Background:
Given the login page is open
Scenario: Valid credentials sign the user in
When the user logs in as "alice" with password "correct-horse"
Then the user sees the dashboard
Scenario Outline: Invalid credentials are rejected
When the user logs in as "<username>" with password "<password>"
Then the user sees an error "<message>"
Examples:
| username | password | message |
| alice | wrongpass | Invalid username or password |
| | correct-horse | Username is required |
| alice | | Password is required |
Given sets up state, When performs the action, Then asserts the
outcome — the same three clauses as RestAssured's given/when/then from
Level 2. A Scenario Outline with Examples is a Cucumber data-driven test,
the Gherkin equivalent of JUnit's @ParameterizedTest.
3. Step definitions¶
package com.example.steps;
import io.cucumber.java.en.*;
import static org.junit.jupiter.api.Assertions.*;
public class LoginSteps {
private final FakeLoginService service = new FakeLoginService();
private String result;
@Given("the login page is open")
public void theLoginPageIsOpen() {
service.reset();
}
@When("the user logs in as {string} with password {string}")
public void theUserLogsIn(String username, String password) {
result = service.login(username, password);
}
@Then("the user sees the dashboard")
public void theUserSeesTheDashboard() {
assertEquals("DASHBOARD", result);
}
@Then("the user sees an error {string}")
public void theUserSeesAnError(String message) {
assertEquals(message, result);
}
}
{string} is a Cucumber expression — it captures the quoted text and hands
it to the method as a String parameter, in declared order. {int} and
{word} work the same way for other types.
class FakeLoginService {
void reset() { }
String login(String username, String password) {
if (username == null || username.isBlank()) return "Username is required";
if (password == null || password.isBlank()) return "Password is required";
if (username.equals("alice") && password.equals("correct-horse")) return "DASHBOARD";
return "Invalid username or password";
}
}
4. Running it¶
package com.example;
import org.junit.platform.suite.api.*;
import static io.cucumber.junit.platform.engine.Constants.GLUE_PROPERTY_NAME;
@Suite
@IncludeEngines("cucumber")
@SelectClasspathResource("features")
@ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "com.example.steps")
public class RunCucumberTest {
}
I ran an equivalent scenario (with the FakeLoginService above, no
Selenium involved) locally through plain JUnit calls to verify the step
logic compiles and passes; running it through the actual Cucumber engine
needs the dependencies above resolved via Maven, which I did not execute
in this environment — treat the runner wiring as reviewed, not executed.
5. Hooks¶
package com.example.steps;
import io.cucumber.java.*;
public class Hooks {
@Before
public void setUp(Scenario scenario) {
System.out.println("Starting: " + scenario.getName());
}
@After
public void tearDown(Scenario scenario) {
if (scenario.isFailed()) {
System.out.println("FAILED: " + scenario.getName());
// attach a screenshot here once WebDriver is wired in (Level 1 Module 08)
}
}
@Before("@requiresLogin")
public void loginFirst() {
// runs only before scenarios tagged @requiresLogin
}
}
@Before/@After run around every scenario, like @BeforeEach/@AfterEach
in JUnit. Tag-scoped hooks (@Before("@tag")) run only for scenarios
carrying that tag — the Gherkin equivalent of a JUnit @Tag filter.
6. Wiring Cucumber to Selenium¶
public class BrowserSteps {
private WebDriver driver;
@Before
public void openBrowser() {
driver = new ChromeDriver();
}
@After
public void closeBrowser() {
driver.quit();
}
@Given("the login page is open")
public void openLoginPage() {
driver.get("https://example.test/login");
}
@When("the user logs in as {string} with password {string}")
public void login(String username, String password) {
driver.findElement(By.id("username")).sendKeys(username);
driver.findElement(By.id("password")).sendKeys(password);
driver.findElement(By.id("submit")).click();
}
@Then("the user sees the dashboard")
public void seesDashboard() {
assertTrue(driver.getCurrentUrl().contains("/dashboard"));
}
}
The feature file doesn't change at all when you swap the fake service for a real browser — that's the point of BDD: the business-readable contract is stable, the implementation underneath it isn't. I did not run this against a real browser here — no Chrome/driver available in this environment — so treat it as reviewed code, matching the WebDriver API used throughout Level 1, not verified output.
7. Testing traps¶
Trap 1 — two step definitions match one step
Cucumber throws DuplicateStepDefinitionException if two @When
patterns both match a line. Keep step text specific and grep your steps
package before adding a near-duplicate.
Trap 2 — steps shared across features that aren't really the same
Given the login page is open reused verbatim across ten features looks
efficient until one feature needs it to also clear cookies. Changing the
shared step then silently changes nine other features' behaviour. Prefer
a slightly longer, more specific phrase over accidental reuse.
Trap 3 — state leaking between scenarios
Step definition classes are instantiated fresh per scenario by default,
but a static field defeats that isolation. A counter or list declared
static in LoginSteps will silently accumulate across scenarios and
make the second scenario in a run depend on the first.
Trap 4 — Scenario Outline with an empty cell
| username | | message | with a genuinely blank cell is easy to
misread in a wide table. Misaligned pipes produce a Then block that
silently receives the wrong column's value. Run --dry-run (or your IDE's
Gherkin linter) after editing a table.
Trap 5 — Gherkin as documentation nobody automates
A feature file with steps that print // TODO and always pass gives
false confidence in a release. Every Given/When/Then needs a step
definition that actually asserts something, or the scenario should be
tagged @wip and excluded from CI.
Cheat sheet¶
| Concept | Syntax |
|---|---|
| Feature file location | src/test/resources/features/*.feature |
| Given/When/Then | preconditions / action / assertion |
| Scenario Outline | Examples: table drives repeated runs |
| String parameter | {string} in step text |
| Number parameter | {int} |
| Run scenarios by tag | @Suite + filter.tags config parameter |
| Hook every scenario | @Before / @After |
| Hook tagged scenarios | @Before("@tagName") |
| JUnit 5 runner | @Suite @IncludeEngines("cucumber") |
| Attach failure context | scenario.isFailed() inside @After |
How It Actually Works¶
A Gherkin line like When the user logs in as "alice" with password
"correct-horse" connects to Java through regular-expression (or Cucumber
Expression) matching against annotation values, resolved at runtime, not
at compile time. Cucumber's cucumber-java module scans your test
classpath for methods annotated @When, @Given, @Then and builds an
internal registry mapping each annotation's pattern string to that method
— @When("the user logs in as {string} with password {string}") compiles
internally to a regex with two capture groups. When the runner executes a
feature file, it parses each Gherkin step into plain text, walks the
registry looking for exactly one pattern that matches, and if it finds one,
extracts the matched groups, converts them to the method's declared
parameter types, and invokes the step definition method reflectively with
those arguments — which is exactly why a step matching two different
@When patterns throws an AmbiguousStepDefinitionsException at runtime
rather than a compile error: nothing checks pattern uniqueness until a
scenario actually tries to run.
Scenario Outline + Examples is a pure text-substitution mechanism
happening before any of that matching occurs: Cucumber's Gherkin parser
expands one outline into N concrete scenarios — one per Examples row — by
literally substituting <username>/<password>/<message> with each
row's values and treating the result as N independent, ordinary
scenarios, each matched against step definitions exactly as described
above. That's why a typo in an <placeholder> name produces a scenario
whose step text still contains the literal angle brackets — the
substitution simply found nothing to replace it with and left it alone —
usually surfacing as a step-matching failure rather than an outline-parsing
error, which is a common early debugging trap.
Exercise¶
- Write
login.featureandLoginStepsexactly as above; run it through the Cucumber JUnit Platform engine and confirm 4/4 scenarios pass. - Add a scenario tagged
@requiresLoginfor "logged-in user logs out", and a@Before("@requiresLogin")hook that logs a message; confirm the hook only fires for that scenario. - Add a fifth
Examplesrow to the outline for a username containing leading/trailing whitespace, and decide (then implement) whetherFakeLoginServiceshould trim it. - Rewrite two manual test cases from your Level 1 test-case document as Gherkin scenarios in a new feature file, with step definitions backed by fakes (no browser).
- Introduce a duplicate step definition on purpose, run the suite, and
paste the exact
DuplicateStepDefinitionExceptionmessage you get — this is what Trap 1 looks like in real output.