Cucumber Testing: BDD Testing for Every Team

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/cucumber and @playwright/test
  • Create features/ directory with one .feature file
  • Write 3 Gherkin scenarios for your most critical user flow
  • Implement step definitions
  • Set up World class with browser context
  • Add Before/After hooks for browser lifecycle
  • Run cucumber-js and 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.

Read more