Cucumber Testing: BDD Testing for Every Team
Cucumber is a BDD (Behavior-Driven Development) testing tool that lets you write tests in plain English using a format called Gherkin. Business stakeholders write scenarios like "Given I'm logged in, When I add an item to the cart, Then the cart shows 1 item" — and developers implement the code that makes those scenarios run. This guide covers Gherkin syntax, step definitions, hooks, data tables, and when Cucumber is worth the overhead.
Key Takeaways
Cucumber bridges business and engineering. The value isn't the tooling — it's having a shared language. When product, QA, and engineering all read the same Gherkin feature files, misunderstandings get caught before code is written.
Gherkin is Given/When/Then. Given sets up the context, When describes the action, Then asserts the outcome. Keep each scenario focused on one behavior.
Step definitions are just functions. The Gherkin text matches a regex or string pattern, which maps to a function. The function does the actual work.
Don't over-BDD. Cucumber has overhead. If your team doesn't have business stakeholders reading the feature files, use plain Jest or Vitest instead. Cucumber earns its keep on cross-functional teams.
Scenario Outlines run one scenario with multiple data rows. Use them when you need to test the same behavior with different inputs. They're much cleaner than copy-pasting scenarios.
What Is Cucumber?
Cucumber is a testing framework that supports Behavior-Driven Development (BDD). It reads plain-text feature files written in a language called Gherkin and maps each line to executable code.
A Cucumber test looks like this:
Feature: User Login
Scenario: Successful login with valid credentials
Given I am on the login page
When I enter "alice@example.com" and "password123"
And I click the Sign In button
Then I should be redirected to the dashboard
And I should see "Welcome, Alice"
This is not a description — it's an executable test. Each line maps to a function that performs the action or makes the assertion.
Why Cucumber?
- Shared understanding: Feature files are readable by non-developers. PMs and QA can review and even write scenarios.
- Living documentation: Tests describe actual behavior, not implementation. They stay up-to-date because they run.
- Acceptance criteria as code: Feature files are the definition of done for each user story.
When Not to Use Cucumber
Cucumber has overhead: maintaining Gherkin files, step definitions, and keeping them in sync. It's worth it when:
- Multiple stakeholders (product, QA, dev) collaborate on test scenarios
- You need readable acceptance tests that non-developers can verify
It's not worth it when:
- Your team is all developers and nobody reads the feature files
- You're building an internal tool with no business stakeholders
- You want to move fast — plain unit tests are simpler
Installing Cucumber
JavaScript (Cucumber.js)
npm install --save-dev @cucumber/cucumber
Add to package.json:
{
"scripts": {
"test:cucumber": "cucumber-js"
}
}
Create cucumber.js config:
module.exports = {
default: {
features: ['features/**/*.feature'],
require: ['features/step_definitions/**/*.js', 'features/support/**/*.js'],
format: ['progress', 'html:reports/cucumber.html'],
parallel: 2
}
}
Python (Behave)
pip install behave
Run:
behave features/
Gherkin Syntax
Basic Structure
Feature: Shopping Cart
As a shopper
I want to add items to my cart
So that I can purchase them later
Background:
Given I am logged in as "alice@example.com"
Scenario: Add item to empty cart
Given my cart is empty
When I add "Running Shoes" to my cart
Then my cart should contain 1 item
And the item should be "Running Shoes"
Scenario: Cart shows correct total
Given my cart is empty
When I add "Running Shoes" priced at "$89.99" to my cart
And I add "Sports Socks" priced at "$12.99" to my cart
Then the cart total should be "$102.98"
Keywords
| Keyword | Meaning |
|---|---|
Feature: |
The feature being tested (one per file) |
Scenario: |
A single test case |
Background: |
Steps that run before every scenario in the feature |
Given |
Initial context/state |
When |
Action taken |
Then |
Expected outcome |
And |
Continuation of the previous Given/When/Then |
But |
Negative continuation |
Step Arguments
Pass data into steps:
# String arguments (quoted)
When I search for "running shoes"
Then I should see "5 results"
# Integer arguments
When I add 3 items to my cart
Then the cart should have 3 items
# Tables
When I fill in the registration form:
| Field | Value |
| Name | Alice |
| Email | alice@example.com |
| Password | SecurePass123 |
Scenario Outline (Data-Driven Tests)
Scenario Outline: Password validation
Given I am on the registration page
When I enter the password "<password>"
Then I should see the message "<message>"
Examples:
| password | message |
| abc | Password must be at least 8 characters |
| password | Password must contain an uppercase letter |
| Password | Password must contain a number |
| Password1 | Password is strong |
This generates 4 test cases from one scenario template. Much cleaner than four separate scenarios.
Tags
Organize and filter scenarios with tags:
@smoke @authentication
Feature: Login
@happy-path
Scenario: Successful login
...
@error-case
Scenario: Wrong password
...
Run specific tags:
cucumber-js --tags "@smoke"
cucumber-js --tags <span class="hljs-string">"@smoke and not @slow"
cucumber-js --tags <span class="hljs-string">"@checkout or @cart"
Writing Step Definitions
Step definitions map Gherkin steps to code. Each step uses a regex or string expression that matches the Gherkin text.
JavaScript Step Definitions
// features/step_definitions/cart.steps.js
import { Given, When, Then, Before } from '@cucumber/cucumber'
import assert from 'node:assert/strict'
import { CartPage } from '../support/pages/CartPage.js'
let cartPage
Before(async function() {
cartPage = new CartPage(this.page)
})
Given('my cart is empty', async function() {
await cartPage.clearCart()
})
When('I add {string} to my cart', async function(productName) {
await cartPage.addProduct(productName)
})
When('I add {string} priced at {string} to my cart', async function(productName, price) {
await cartPage.addProduct(productName)
})
Then('my cart should contain {int} item(s)', async function(count) {
const itemCount = await cartPage.getItemCount()
assert.equal(itemCount, count)
})
Then('the item should be {string}', async function(productName) {
const items = await cartPage.getItemNames()
assert.ok(items.includes(productName), `Expected cart to contain "${productName}"`)
})
Then('the cart total should be {string}', async function(total) {
const cartTotal = await cartPage.getTotal()
assert.equal(cartTotal, total)
})
Expression Types
// String parameter: matches any quoted string
Given('I search for {string}', (query) => { ... })
// Integer parameter
When('I add {int} items', (count) => { ... })
// Float parameter
Then('the price should be {float}', (price) => { ... })
// Word parameter (no spaces, no quotes)
Given('I am logged in as {word}', (role) => { ... })
// Regex (full control)
Given(/^I have (\d+) items? in my (cart|wishlist)$/, (count, list) => { ... })
Data Tables
When I fill in the registration form:
| Field | Value |
| Name | Alice |
| Email | alice@example.com |
| Password | Password123 |
When('I fill in the registration form:', async function(dataTable) {
const data = dataTable.rowsHash()
// data = { Name: 'Alice', Email: 'alice@example.com', Password: 'Password123' }
for (const [field, value] of Object.entries(data)) {
await this.page.getByLabel(field).fill(value)
}
})
// For tables with multiple columns:
When('I have these products:', async function(dataTable) {
const rows = dataTable.hashes()
// rows = [{ name: 'Widget', price: '9.99', qty: '2' }, ...]
for (const row of rows) {
await addProduct(row.name, parseFloat(row.price), parseInt(row.qty))
}
})
Hooks
// features/support/hooks.js
import { Before, After, BeforeAll, AfterAll, setDefaultTimeout } from '@cucumber/cucumber'
setDefaultTimeout(30 * 1000) // 30 second timeout
BeforeAll(async function() {
// Runs once before all scenarios
// Set up shared test infrastructure
})
AfterAll(async function() {
// Runs once after all scenarios
// Tear down shared infrastructure
})
Before(async function(scenario) {
// Runs before each scenario
// this.scenario = scenario (has tags, name, etc.)
console.log(`Starting: ${scenario.pickle.name}`)
})
After(async function(scenario) {
// Runs after each scenario
if (scenario.result.status === 'FAILED') {
// Take screenshot on failure
const screenshot = await this.page.screenshot()
await this.attach(screenshot, 'image/png')
}
})
// Tagged hooks
Before({ tags: '@authenticated' }, async function() {
await this.page.goto('/login')
await this.page.fill('[name="email"]', 'test@example.com')
await this.page.fill('[name="password"]', 'password123')
await this.page.click('button[type="submit"]')
})
Integrating with Playwright
Cucumber works well with Playwright for browser automation:
// features/support/world.js
import { setWorldConstructor, World } from '@cucumber/cucumber'
import { chromium } from '@playwright/test'
class CustomWorld extends World {
async openBrowser() {
this.browser = await chromium.launch()
this.context = await this.browser.newContext()
this.page = await this.context.newPage()
}
async closeBrowser() {
await this.browser.close()
}
}
setWorldConstructor(CustomWorld)
// features/support/hooks.js
import { Before, After } from '@cucumber/cucumber'
Before(async function() {
await this.openBrowser()
})
After(async function() {
await this.closeBrowser()
})
Now every step definition has access to this.page (the Playwright page object).
Python Example (Behave)
If your stack is Python, Behave is the equivalent:
# features/login.feature
Feature: Login
Scenario: Successful login
Given I am on the login page
When I enter "admin" and "password"
Then I should see the dashboard
# features/steps/login_steps.py
from behave import given, when, then
@given('I am on the login page')
def step_on_login_page(context):
context.browser.get('/login')
@when('I enter "{username}" and "{password}"')
def step_enter_credentials(context, username, password):
context.browser.find_element_by_name('username').send_keys(username)
context.browser.find_element_by_name('password').send_keys(password)
context.browser.find_element_by_css_selector('button[type=submit]').click()
@then('I should see the dashboard')
def step_see_dashboard(context):
assert '/dashboard' in context.browser.current_url
CI/CD Integration
# .github/workflows/cucumber.yml
name: Cucumber Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- name: Install Playwright
run: npx playwright install chromium --with-deps
- name: Run Cucumber tests
run: npm run test:cucumber
- uses: actions/upload-artifact@v4
if: always()
with:
name: cucumber-report
path: reports/cucumber.html
Best Practices
Write Scenarios from the User's Perspective
# Bad: describes implementation
Given the user table has an entry with email "alice@example.com"
When the POST /api/auth endpoint is called with matching credentials
Then the response status should be 200
# Good: describes user behavior
Given Alice has registered with email "alice@example.com"
When she signs in with her credentials
Then she should see her personal dashboard
Keep Scenarios Short and Focused
# Bad: too much in one scenario
Scenario: Complete checkout
Given I register a new account
And I verify my email
And I add 3 items to my cart
And I apply a discount code
When I complete checkout with a credit card
Then I should receive a confirmation email
And my order should appear in my order history
And my inventory should decrease by 3
# Better: split into focused scenarios, share state via Background or before hooks
Scenario: Checkout with discount code reduces total
Given I have items worth $100 in my cart
When I apply the discount code "SAVE20"
Then the cart total should be "$80.00"
One Scenario Per Behavior
Each scenario should test exactly one thing. If a scenario needs many steps to set up context, consider whether the setup belongs in a Background block or a Before hook.
Don't Use Cucumber for Unit Tests
Cucumber is for acceptance/integration tests. Unit tests should stay in Jest/Vitest — they're faster, simpler, and don't need Gherkin.
Cucumber vs Plain E2E Tests
| Cucumber | Playwright/Cypress | |
|---|---|---|
| Readable by PMs/QA | Yes | No (code) |
| Setup overhead | High | Low |
| Test speed | Same | Same |
| Best for | Cross-functional teams | Dev-only teams |
| Maintenance | Higher | Lower |
| Debugging | Harder | Easier |
Getting Started Checklist
- Install
@cucumber/cucumberand@playwright/test - Create
features/directory with one.featurefile - Write 3 Gherkin scenarios for your most critical user flow
- Implement step definitions
- Set up
Worldclass with browser context - Add
Before/Afterhooks for browser lifecycle - Run
cucumber-jsand see green
Need readable acceptance tests without writing Gherkin by hand? HelpMeTest generates test scenarios from your application's behavior — describe what it should do in plain English.