End-to-end test automation with Selenium and Cucumber
Set up end-to-end tests in Node.js with selenium-webdriver and Cucumber: readable Gherkin scenarios, a shared browser per scenario, screenshots on failure, and a CI workflow.
End-to-end (E2E) tests drive your application the way a user would: open a browser, click, type, and check what appears. They catch the bugs unit tests can't, like a broken form, a wrong redirect or a missing button.
In this guide we'll use two tools:
- selenium-webdriver to control a real browser (Chrome here, but Firefox, Edge and Safari work too).
- Cucumber to write the scenarios in plain English (Gherkin), so product owners and testers can read them too.
By the end you'll have a project that runs locally with a visible browser, runs headless in CI, and saves a screenshot of every failing step.
What you need
- Node.js 22 or newer
- Google Chrome
You don't need to install ChromeDriver yourself. Since version 4.6, Selenium ships with Selenium Manager, which finds your browser and downloads the matching driver on first run.
Set up the project
mkdir e2e && cd e2e
npm init -y
npm install --save-dev @cucumber/cucumber selenium-webdriverThe Cucumber package used to be called
cucumber. It's now@cucumber/cucumber; if you're updating an old project, swap the package and therequirelines.
Create this layout:
e2e/
├── cucumber.js
├── package.json
└── features/
├── web-form.feature
├── step_definitions/
│ └── web-form.steps.js
└── support/
├── world.js
└── hooks.jsConfigure Cucumber
cucumber.js holds the default options, so npx cucumber-js needs no flags:
// cucumber.js
module.exports = {
default: {
paths: ["features/**/*.feature"],
require: ["features/**/*.js"],
format: ["progress-bar", "html:reports/cucumber-report.html"],
},
}Add scripts to package.json:
{
"scripts": {
"test:e2e": "cucumber-js",
"test:e2e:headless": "HEADLESS=1 cucumber-js"
}
}Write the scenarios
We'll test Selenium's own demo form, a stable page made for exactly this. Testing against a real search engine tends to break: they show consent pages and block automated browsers.
# features/web-form.feature
Feature: Web form
As a visitor
I want to submit the web form
So that my input is received
Background:
Given I open the web form
Scenario: Submitting the form shows a confirmation
When I type "Hello Selenium" into the text input
And I submit the form
Then I should see the message "Received!"
Scenario Outline: The form accepts different inputs
When I type "<text>" into the text input
And I submit the form
Then I should see the message "Received!"
Examples:
| text |
| short |
| with spaces |
| 12345 |Background runs before every scenario, and a Scenario Outline runs once per row of
Examples.
Create a browser for each scenario
Cucumber gives every scenario a fresh World object (this inside steps and hooks). We'll
put the browser on it, so each scenario starts clean.
// features/support/world.js
const { setWorldConstructor, setDefaultTimeout, World } = require("@cucumber/cucumber")
const { Builder, Browser } = require("selenium-webdriver")
const chrome = require("selenium-webdriver/chrome")
setDefaultTimeout(30 * 1000)
class BrowserWorld extends World {
async openBrowser() {
const options = new chrome.Options()
options.addArguments("--window-size=1366,900")
if (process.env.HEADLESS) options.addArguments("--headless=new")
this.driver = await new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).build()
}
}
setWorldConstructor(BrowserWorld)The hooks open the browser before each scenario, and close it afterwards. If a scenario fails, they attach a screenshot to the report first.
// features/support/hooks.js
const { Before, After, Status } = require("@cucumber/cucumber")
Before(async function () {
await this.openBrowser()
})
After(async function ({ result }) {
if (result?.status === Status.FAILED && this.driver) {
const png = await this.driver.takeScreenshot()
this.attach(Buffer.from(png, "base64"), "image/png")
}
await this.driver?.quit()
})Write the steps
Each Gherkin line maps to a step definition. {string} captures a quoted value from the scenario.
// features/step_definitions/web-form.steps.js
const { Given, When, Then } = require("@cucumber/cucumber")
const { By, until } = require("selenium-webdriver")
const assert = require("node:assert/strict")
const FORM_URL = "https://www.selenium.dev/selenium/web/web-form.html"
Given("I open the web form", async function () {
await this.driver.get(FORM_URL)
assert.equal(await this.driver.getTitle(), "Web form")
})
When("I type {string} into the text input", async function (text) {
const input = await this.driver.findElement(By.id("my-text-id"))
await input.clear()
await input.sendKeys(text)
})
When("I submit the form", async function () {
await this.driver.findElement(By.css("button[type='submit']")).click()
})
Then("I should see the message {string}", async function (expected) {
const message = await this.driver.wait(until.elementLocated(By.id("message")), 5000)
assert.equal(await message.getText(), expected)
})A few habits that keep E2E tests reliable:
- Wait for things, don't sleep.
driver.wait(until.elementLocated(...))returns as soon as the element exists. A fixedsleepis either too short (flaky) or too long (slow). - Prefer stable locators. IDs and
data-testidattributes survive redesigns; long CSS paths and XPath tied to layout don't. - One browser per scenario. Shared browsers leak cookies and state between tests.
Run the tests
npm run test:e2e # opens Chrome so you can watch
npm run test:e2e:headless # no window, as in CI4 scenarios (4 passed)
24 steps (24 passed)Open reports/cucumber-report.html in a browser to see every scenario and step, with screenshots
attached to any failures. To learn more about reports, read
how to generate Cucumber test reports.
Run them in CI
GitHub's ubuntu-latest runners come with Chrome installed, and Selenium Manager fetches the
driver, so the workflow is short:
# .github/workflows/e2e.yml
name: e2e
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run test:e2e:headless
- uses: actions/upload-artifact@v4
if: always()
with:
name: cucumber-report
path: reports/if: always() uploads the report even when tests fail, which is when you need it most.
Growing the suite
For a larger application, move locators and actions into page objects (one class per page),
so steps read like await formPage.submit("Hello") and a changed selector is fixed in one place.
I wrote about structuring bigger suites in two parts:
part one
and
part two.
