Cucumber & Gherkin
A practical reference for Cucumber's plain-language test format and the step-definition glue that makes it executable. Examples cover both the JavaScript/TypeScript runner (@cucumber/cucumber) and Cucumber-JVM.
Gherkin Syntax
Gherkin is the human-readable DSL. A .feature file uses these keywords.
| Keyword | Purpose |
|---|---|
Feature: | Top-level — name and (optional) narrative for the file |
Background: | Steps that run before every scenario in the file |
Rule: | Group scenarios under a business rule (Gherkin 6+) |
Scenario: / Example: | A single test case |
Scenario Outline: / Scenario Template: | Parameterised scenario; pair with Examples: |
Given / When / Then | Step keywords — context, action, outcome |
And / But | Continuation of the previous keyword (reads naturally) |
* | Generic step bullet — same as the previous keyword |
# | Line comment |
Feature: User Login
As a registered user
I want to log in to my account
So that I can access my dashboard
Background:
Given the login page is displayed
Scenario: Successful login with valid credentials
When I enter username "testuser@qa.codes"
And I enter password "SecurePass123"
And I click the login button
Then I should be redirected to the dashboard
And I should see a welcome message
Scenario: Invalid password
When I enter username "testuser@qa.codes"
And I enter password "wrong"
And I click the login button
Then I should see the error "Invalid email or password"Scenario Outline & Examples
Use Scenario Outline when the same flow runs with different data. Placeholders use <name> syntax.
Scenario Outline: Login with different roles
Given I am a user with role "<role>"
When I log in with "<username>" and "<password>"
Then I should see the "<dashboard>" dashboard
Examples: Valid users
| role | username | password | dashboard |
| admin | admin@test.com | Admin123 | Admin |
| editor | edit@test.com | Edit123 | Editor |
| viewer | view@test.com | View123 | Viewer |
Examples: Inactive users
| role | username | password | dashboard |
| inactive | dorm@test.com | Dorm123 | Locked |Cucumber generates one scenario per row. Each Examples block can be tagged separately (e.g. @smoke on the first table, @regression on the second).
Data Tables
Pass tabular data directly into a step. The step text always ends with : when followed by a table.
Given the following users exist:
| name | email | role |
| Ada | ada@test.com | admin |
| Bob | bob@test.com | viewer |
| Carol | carol@test.com | editor |
When I delete the users:
| ada@test.com |
| bob@test.com |
Then the user store should contain:
| email |
| carol@test.com |Reading data tables — TypeScript / JavaScript
import { Given, DataTable } from "@cucumber/cucumber";
Given("the following users exist:", async function (table: DataTable) {
const users = table.hashes();
// [{ name: "Ada", email: "ada@test.com", role: "admin" }, ...]
const raw = table.raw();
// [["name", "email", "role"], ["Ada", "ada@test.com", "admin"], ...]
const rows = table.rows();
// [["Ada", "ada@test.com", "admin"], ...] — header dropped
for (const u of users) {
await db.users.insert(u);
}
});
// Single column → list
Given("the user emails:", function (table: DataTable) {
const emails = table.raw().flat(); // ["ada@test.com", "bob@test.com"]
});
// Two columns → key-value map
Given("the config:", function (table: DataTable) {
const config = table.rowsHash(); // { base_url: "https://...", timeout: "30" }
});Reading data tables — Java
@Given("the following users exist:")
public void theFollowingUsersExist(DataTable table) {
List<Map<String, String>> users = table.asMaps();
List<List<String>> raw = table.asLists();
List<String> emails = table.asList(); // single column
Map<String, String> config = table.transpose().asMap(); // 2 columns → map
}Step Definitions
TypeScript / JavaScript
import { Given, When, Then } from "@cucumber/cucumber";
import { expect } from "chai";
Given("I am on the {string} page", async function (pageName: string) {
await this.page.goto(`/${pageName.toLowerCase()}`);
});
When("I click the {string} button", async function (label: string) {
await this.page.getByRole("button", { name: label }).click();
});
Then("I should see {int} results", async function (count: number) {
const items = await this.page.getByTestId("result").all();
expect(items).to.have.lengthOf(count);
});
Then("I should see an error", async function () {
await expect(this.page.getByRole("alert")).toBeVisible();
});Java
@Given("I am on the {string} page")
public void iAmOnThePage(String pageName) {
driver.get(BASE_URL + "/" + pageName.toLowerCase());
}
@When("I click the {string} button")
public void iClickTheButton(String label) {
driver.findElement(By.xpath(
"//button[normalize-space(.)='" + label + "']")).click();
}
@Then("I should see {int} results")
public void iShouldSeeResults(int count) {
List<WebElement> rows = driver.findElements(By.cssSelector("[data-testid=result]"));
Assert.assertEquals(count, rows.size());
}Cucumber Expressions vs regex
Cucumber Expressions (the {string}, {int} form) are simpler and the recommended default. Regex is the escape hatch.
| Expression | Matches | Captured as |
|---|---|---|
{string} | "quoted" or 'quoted' text | String |
{int} | integers | int / number |
{float} | floats | float / number |
{word} | a single non-whitespace word | String |
{} (anonymous) | any content | type inferred from step definition |
Custom parameter type — TypeScript:
import { defineParameterType } from "@cucumber/cucumber";
defineParameterType({
name: "color",
regexp: /red|green|blue|yellow/,
transformer: (s) => s,
});
When("I select the {color} card", function (color) { /* ... */ });Hooks
Hooks live in step-definition files (or any file Cucumber loads).
TypeScript / JavaScript
import { Before, After, BeforeAll, AfterAll, BeforeStep, AfterStep, Status } from "@cucumber/cucumber";
BeforeAll(async function () {
// runs once before any scenario
await migrate();
});
Before({ tags: "@smoke" }, async function () {
// runs only for scenarios tagged @smoke
await seedSmokeData();
});
Before(async function ({ pickle }) {
this.startedAt = Date.now();
console.log(`▶ ${pickle.name}`);
});
After(async function ({ result, pickle }) {
if (result?.status === Status.FAILED) {
const png = await this.page.screenshot();
this.attach(png, "image/png");
}
await this.page.context().close();
});
BeforeStep(function ({ pickleStep }) { /* ... */ });
AfterStep(function ({ pickleStep, result }) { /* ... */ });
AfterAll(async function () {
await teardown();
});Java
import io.cucumber.java.Before;
import io.cucumber.java.After;
import io.cucumber.java.Scenario;
public class Hooks {
@Before
public void setUp() {
driver = new ChromeDriver();
}
@Before("@smoke")
public void smokeOnly() {
seedSmokeData();
}
@After(order = 0)
public void tearDown(Scenario scenario) {
if (scenario.isFailed()) {
byte[] png = ((TakesScreenshot) driver).getScreenshotAs(OutputType.BYTES);
scenario.attach(png, "image/png", "failure");
}
driver.quit();
}
}Hook ordering
BeforeAll→Before(highorderfirst) → step →After(loworderfirst) →AfterAll- Multiple
Beforehooks: declared order in JS,orderattribute in Java (lower = earlier). - Multiple
Afterhooks: reverse ofBefore— last declared runs first.
Tags & Filtering
Tag features and scenarios with @name. Tags on a Feature apply to every scenario in the file.
@smoke
Feature: Login
@critical
Scenario: Valid login
...
@wip
Scenario: Reset password
...Running specific tags
# Cucumber-JS
npx cucumber-js --tags "@smoke"
npx cucumber-js --tags "@smoke and not @wip"
npx cucumber-js --tags "(@login or @signup) and @critical"
# Cucumber-JVM (with Maven)
mvn test -Dcucumber.filter.tags="@smoke and not @wip"Common tag conventions
| Tag | Meaning |
|---|---|
@smoke | Critical-path subset — runs first, fails fast |
@regression | Full suite — runs nightly |
@wip | Work in progress — exclude from default runs |
@skip / @ignore | Permanently disabled (with reason in the file) |
@manual | Documents flows that aren't automated yet |
@flaky | Quarantine — re-runs allowed |
Doc Strings
For step arguments that span multiple lines or contain special characters.
Given the following JSON payload:
"""json
{
"name": "Test User",
"email": "test@qa.codes",
"roles": ["admin", "editor"]
}
"""
When I POST it to "/api/users"
Then the response should be:
"""xml
<user>
<id>42</id>
<email>test@qa.codes</email>
</user>
"""Receiving in step definitions:
Given("the following JSON payload:", function (payload: string) {
this.body = JSON.parse(payload);
});@Given("the following JSON payload:")
public void theFollowingJsonPayload(String payload) {
this.body = new ObjectMapper().readTree(payload);
}The optional json / xml after the opening """ is a content-type hint — it doesn't change parsing.
World Object & Context Sharing
Steps run in the same scenario need to share state — the user that was created in Given, the response captured in When, etc.
TypeScript — World
import { setWorldConstructor, World, IWorldOptions } from "@cucumber/cucumber";
import { Browser, Page, chromium } from "playwright";
export class TestWorld extends World {
browser!: Browser;
page!: Page;
user?: { id: number; email: string };
apiResponse?: Response;
constructor(options: IWorldOptions) {
super(options);
}
async launchBrowser() {
this.browser = await chromium.launch();
this.page = await this.browser.newPage();
}
}
setWorldConstructor(TestWorld);
// In a step:
Given("a user is logged in", async function (this: TestWorld) {
this.user = await api.createUser();
await this.page.goto("/login");
// ...
});Java — DI containers
Cucumber-JVM doesn't have a World; you use a DI container instead.
// PicoContainer is bundled — easiest option
public class TestContext {
public WebDriver driver;
public User currentUser;
public Response apiResponse;
}
public class LoginSteps {
private final TestContext ctx;
public LoginSteps(TestContext ctx) { // PicoContainer wires this
this.ctx = ctx;
}
@Given("a user is logged in")
public void aUserIsLoggedIn() {
ctx.currentUser = api.createUser();
// ...
}
}For Spring, swap to cucumber-spring and annotate with @SpringBootTest. For Guice, use cucumber-guice.
Reporting
Built-in formatters
npx cucumber-js --format progress # dots
npx cucumber-js --format pretty # human-readable
npx cucumber-js --format json:report.json # machine output
npx cucumber-js --format html:report.html # built-in HTML
npx cucumber-js --format @cucumber/pretty-formatterMultiple formatters can run at once — declare each with --format.
Cucumber HTML report
The official HTML formatter (@cucumber/html-formatter) ships an interactive report with feature/scenario hierarchy, attachments (screenshots, logs), and step timing.
npx cucumber-js --format html:reports/cucumber.htmlAllure adapter
# Install allure-cucumberjs (or allure-cucumber for JVM)
npm install -D allure-cucumberjs allure-commandline
# In cucumber config, add the formatter:
# format: ['allure-cucumberjs/reporter']
# After the run:
npx allure generate ./allure-results --clean -o allure-report
npx allure open allure-reportJUnit-XML for CI
npx cucumber-js --format junit:reports/results.xmlGitHub Actions / Jenkins / GitLab pick this format up natively. Pair with a JUnit reporter action so failures surface in PR checks.
MasterThought (Cucumber-JVM)
The de-facto HTML reporter for the JVM ecosystem — feeds off Cucumber JSON output:
mvn test -Dcucumber.plugin="json:target/cucumber.json"
# Then a Maven plugin or a Jenkins step generates HTML from the JSON.