All guides

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.

4 min readUpdated November 8, 2026

  • Selenium
  • Cucumber
  • JavaScript
  • E2E
  • End-to-End Testing

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-webdriver

The Cucumber package used to be called cucumber. It's now @cucumber/cucumber; if you're updating an old project, swap the package and the require lines.

Create this layout:

e2e/
├── cucumber.js
├── package.json
└── features/
    ├── web-form.feature
    ├── step_definitions/
    │   └── web-form.steps.js
    └── support/
        ├── world.js
        └── hooks.js

Configure 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 fixed sleep is either too short (flaky) or too long (slow).
  • Prefer stable locators. IDs and data-testid attributes 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 CI
4 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.

Marcus Chukwuoma

DevOps / Software Engineer. I write practical guides on infrastructure, CI/CD and testing.