Cucumber BDD: The Secret Weapon Behind Every Successful Project I Deliver

Let me tell you about the single most important tool in my development arsenal. It is not a fancy IDE. It is not some AI code generator. It is Cucumber, and it has been the backbone of every successful project I have delivered for the past decade. From enterprise clients like the British Army and Volvo Group to SaaS platforms like Stint.co and Regios.at, every single one runs on a foundation of Behaviour Driven Development. This is not optional. This is how professional software gets built.

Let me tell you about the single most important tool in my development arsenal. It is not a fancy IDE. It is not some AI code generator. It is Cucumber, and it has been the backbone of every successful project I have delivered for the past decade. From enterprise clients like the British Army and Volvo Group to SaaS platforms like Stint.co and Regios.at, every single one runs on a foundation of Behaviour Driven Development. This is not optional. This is how professional software gets built.

I am not exaggerating when I say Cucumber has saved my clients millions in avoided bugs, rework, and miscommunication. By the time you finish reading this post, you will understand exactly why, and you will wonder how you ever built software without it.

What Is Behaviour Driven Development?

Before diving into Cucumber specifically, let me explain the philosophy behind it.

Traditional software development works something like this:

  1. Business person writes requirements document
  2. Developer reads document, interprets it their own way
  3. Developer writes code based on their interpretation
  4. Tester tests based on their interpretation of the same document
  5. Business person sees the result and says "that is not what I meant"
  6. Everyone argues about whose interpretation was correct
  7. Expensive rework happens
  8. Repeat until budget runs out or everyone gives up

This is insane. We have been building software like this for decades and wondering why projects fail.

Behaviour Driven Development (BDD) fixes this by making everyone speak the same language. Instead of vague requirements documents that get interpreted differently by every reader, BDD uses structured, unambiguous specifications written in plain English that are also executable as tests.

The key insight: if the specification is the test, it cannot be misinterpreted. The software either behaves as specified or it does not. There is no room for "well, I thought you meant..." because the behaviour is defined precisely and verified automatically.

Enter Cucumber

Cucumber is the tool that makes BDD practical. It lets you write specifications in a language called Gherkin, which reads like plain English but has enough structure to be parsed by a computer and executed as tests.

Here is what a Cucumber feature file looks like:

# features/user_login.feature
# This is the actual specificaiton AND the test
# If this passes, the feature works. Simple as.

Feature: User Login
  As a registered user
  I want to log into my account
  So that I can access my personalised dashboard

  Background:
    Given a user exists with email "[email protected]" and password "correcthorse"

  Scenario: Successful login with valid credentials
    Given I am on the login page
    When I fill in "Email" with "[email protected]"
    And I fill in "Password" with "correcthorse"
    And I press "Sign In"
    Then I should see "Welcome back, Sarah!"
    And I should be on the dashboard page

  Scenario: Failed login with wrong password
    Given I am on the login page
    When I fill in "Email" with "[email protected]"
    And I fill in "Password" with "wrongpassword"
    And I press "Sign In"
    Then I should see "Invalid email or password"
    And I should still be on the login page

  Scenario: Failed login with non existent email
    Given I am on the login page
    When I fill in "Email" with "[email protected]"
    And I fill in "Password" with "whatever"
    And I press "Sign In"
    Then I should see "Invalid email or password"
    And I should still be on the login page

Read that out loud. Anyone can understand it. Your product manager can understand it. Your CEO can understand it. Your nan could probably understand it. That is the point.

But here is the magic: this is not just documentation. This is an executable test. When I run cucumber features/user_login.feature, Cucumber actually opens a browser, navigates to the login page, fills in the fields, clicks the button, and verifies the results. If the application does not behave exactly as specified, the test fails.

Why I Use Cucumber On Every Single Project

Let me be concrete about where I have used this:

British Army Mission critical systems where bugs are not just inconvenient, they can be dangerous. Every requirement is captured in Gherkin, reviewed by stakeholders, and automatically verified.

Volvo Group Enterprise logistics systems handling millions of pounds in transactions. The feature files serve as the contract between development and business.

Stint.co SaaS marketing platform where features ship weekly. Cucumber ensures new features do not break existing functionality.

Regios.at Regional discovery platform with complex search and filtering logic. The feature files document every edge case.

Icebreaker.com E-commerce with intricate product configuration. Cucumber tests verify every combination works correctly.

Auto-Prammer.at Automotive marketplace with sophisticated matching algorithms. BDD specifications ensure the matching logic behaves exactly as the business intends.

The pattern is consistent: complex requirements, high stakes, and zero tolerance for "oops, that is not what we meant." Cucumber eliminates that entire category of problem.

The Gherkin Language: Essential Concepts

Gherkin has a small vocabulary that is easy to learn but powerful enough to describe any behaviour. Let me walk you through each element.

Feature

Every .feature file starts with a Feature block that describes what capability is being specified:

Feature: Shopping Cart Management
  As an online shopper
  I want to manage items in my shopping cart
  So that I can purchase multiple products in one transaction

The three line description follows a standard format:

  • As a [type of user]
  • I want [some capability]
  • So that [business value]

This is not just fluff. It forces you to articulate why the feature exists and who it is for. If you cannot fill in these three lines, you do not understand the feature well enough to build it.

Scenario

Scenarios are the individual test cases. Each scenario describes one specific behaviour:

Scenario: Adding a product to an empty cart
  Given my shopping cart is empty
  When I add "Blue Widget" to my cart
  Then my cart should contain 1 item
  And the cart total should be "£29.99"

A good scenario is:

  • Independent It does not rely on other scenarios running first
  • Focused It tests one specific behaviour
  • Clear Anyone can understand what is being tested

Given, When, Then

These are the building blocks of every scenario:

Given Sets up the initial context. What state is the system in before the action?

Given I am logged in as an admin user
Given there are 5 products in the database
Given the payment gateway is configured for test mode

When Describes the action being performed. What does the user do?

When I click "Delete Product"
When I submit the order form
When 24 hours have passed

Then Specifies the expected outcome. What should happen as a result?

Then I should see "Product deleted successfully"
Then an order confirmation email should be sent
Then the product should no longer appear in search results

And, But

These are used to chain multiple steps of the same type:

Scenario: Complex order with multiple conditions
  Given I am logged in as "[email protected]"
  And I have a 20% discount code "SAVE20"
  And my shipping address is set to London
  When I add "Fancy Gadget" to my cart
  And I apply the discount code
  And I proceed to checkout
  Then I should see the discounted price of "£79.99"
  And I should see free shipping applied
  But I should not see the express delivery option

The But keyword is just syntactic sugar for And with negative connotations. It reads more naturally when describing something that should not happen.

Background

When multiple scenarios share the same setup, use a Background block:

Feature: Admin Product Management
  Background:
    Given I am logged in as an admin
    And I am on the product management page

  Scenario: Creating a new product
    When I click "Add New Product"
    And I fill in the product details
    And I click "Save"
    Then I should see "Product created successfully"

  Scenario: Editing an existing product
    Given a product "Old Widget" exists
    When I click "Edit" next to "Old Widget"
    And I change the name to "New Widget"
    And I click "Save"
    Then I should see "Product updated successfully"

  Scenario: Deleting a product
    Given a product "Unwanted Widget" exists
    When I click "Delete" next to "Unwanted Widget"
    And I confirm the deletion
    Then I should see "Product deleted"
    And "Unwanted Widget" should not appear in the product list

The Background runs before every scenario in the file. This keeps scenarios focused on what makes them unique rather than repeating setup steps.

Step Definitions: Where The Magic Happens

Feature files describe what should happen. Step definitions tell Cucumber how to make it happen. They are the glue between plain English specifications and actual code.

Here is a step definition file:

# features/step_definitions/authentication_steps.rb
# These steps handle all the loggin in and out stuff
# Reused across basicaly every feature file

Given('I am logged in as {string}') do |email|
  # Find or create the user factory bot handles the details
  @current_user = User.find_by(email: email) || create(:user, email: email)
  
  # Actually log them in via the UI
  # Could use a faster method but this tests the real flow
  visit login_path
  fill_in 'Email', with: email
  fill_in 'Password', with: 'password123'  # all test users have this
  click_button 'Sign In'
  
  # Sanity check make sure we actualy logged in
  expect(page).to have_content('Welcome')
end

Given('I am logged in as an admin') do
  # Reuse the other step but with an admin user
  # DRY princples apply to step defs too innit
  @current_user = create(:user, :admin)
  step %(I am logged in as "#{@current_user.email}")
end

Given('I am not logged in') do
  # Make sure no session exists
  # Sometimes tests leave crud behind
  Capybara.reset_sessions!
end

When('I log out') do
  click_link 'Log Out'
end

Then('I should be logged in') do
  expect(page).to have_link('Log Out')
  expect(page).not_to have_link('Sign In')
end

Then('I should not be logged in') do
  expect(page).to have_link('Sign In')
  expect(page).not_to have_link('Log Out')
end
# features/step_definitions/navigation_steps.rb
# Generic navigation steps used absolutley everywhere
# Keep these simple and resuable

Given('I am on the home page') do
  visit root_path
end

Given('I am on the login page') do
  visit login_path
end

Given('I am on the {string} page') do |page_name|
  # Map human readable names to paths
  # Add new pages here as needed
  path = case page_name.downcase
         when 'home' then root_path
         when 'login' then login_path
         when 'register', 'registration' then new_user_registration_path
         when 'dashboard' then dashboard_path
         when 'products' then products_path
         when 'cart', 'shopping cart' then cart_path
         when 'checkout' then checkout_path
         when 'admin' then admin_root_path
         else
           raise "Dont know how to navigate to '#{page_name}' add it to navigation_steps.rb"
         end
  visit path
end

Then('I should be on the {string} page') do |page_name|
  # Same mapping as above bit repetative but whatever
  expected_path = case page_name.downcase
                  when 'home' then root_path
                  when 'login' then login_path
                  when 'dashboard' then dashboard_path
                  when 'products' then products_path
                  else
                    raise "Dont know path for '#{page_name}'"
                  end
  expect(current_path).to eq(expected_path)
end

Then('I should still be on the {string} page') do |page_name|
  # Just an alias really reads better in some scenarios
  step %(I should be on the "#{page_name}" page)
end
# features/step_definitions/form_steps.rb
# Generic form interaciton steps
# These get used in almost every feature

When('I fill in {string} with {string}') do |field, value|
  fill_in field, with: value
end

When('I select {string} from {string}') do |value, field|
  select value, from: field
end

When('I check {string}') do |checkbox|
  check checkbox
end

When('I uncheck {string}') do |checkbox|
  uncheck checkbox
end

When('I choose {string}') do |radio_button|
  choose radio_button
end

When('I press {string}') do |button|
  click_button button
end

When('I click {string}') do |link_or_button|
  # Try button first, then link
  # Covers most cases without needing seperate steps
  begin
    click_button link_or_button
  rescue Capybara::ElementNotFound
    click_link link_or_button
  end
end

When('I attach the file {string} to {string}') do |file_path, field|
  # For file uploads path is relative to features/support/fixtures
  full_path = Rails.root.join('features', 'support', 'fixtures', file_path)
  attach_file field, full_path
end
# features/step_definitions/assertion_steps.rb
# Checking stuff on the page
# Keep these generic so they work evrywhere

Then('I should see {string}') do |text|
  expect(page).to have_content(text)
end

Then('I should not see {string}') do |text|
  expect(page).not_to have_content(text)
end

Then('I should see {string} within {string}') do |text, selector|
  within(selector) do
    expect(page).to have_content(text)
  end
end

Then('I should see a {string} message') do |message_type|
  # Works with flash messages styled with classes
  # Adjust selectors for your CSS framework
  case message_type.downcase
  when 'success'
    expect(page).to have_css('.flash-success, .alert-success, .notice')
  when 'error'
    expect(page).to have_css('.flash-error, .alert-danger, .alert')
  when 'warning'
    expect(page).to have_css('.flash-warning, .alert-warning')
  end
end

Then('I should see a link to {string}') do |link_text|
  expect(page).to have_link(link_text)
end

Then('I should not see a link to {string}') do |link_text|
  expect(page).not_to have_link(link_text)
end

Then('the {string} field should contain {string}') do |field, value|
  expect(find_field(field).value).to eq(value)
end

Then('the {string} checkbox should be checked') do |checkbox|
  expect(find_field(checkbox)).to be_checked
end

Then('the {string} checkbox should not be checked') do |checkbox|
  expect(find_field(checkbox)).not_to be_checked
end

Then('I should see {int} items') do |count|
  # Generic count check looks for common list patterns
  expect(page).to have_css('.item, .list-item, tr.item-row', count: count)
end

Then('the page should have title {string}') do |title|
  expect(page).to have_title(title)
end

Advanced Cucumber Techniques

Now that you understand the basics, let me show you some advanced patterns I use on enterprise projects.

Scenario Outlines: Data Driven Testing

When you need to test the same behaviour with different inputs, Scenario Outlines eliminate repetition:

# features/pricing_tiers.feature
# Testing all the different pricing tiers
# Way cleaner than writing 10 seperate scenarios

Feature: Subscription Pricing
  As a potential customer
  I want to see accurate pricing for different plans
  So that I can choose the right subscription

  Scenario Outline: Displaying correct prices for each tier
    Given I am on the pricing page
    When I select the "<plan>" plan
    Then I should see the price "<monthly_price>" per month
    And I should see the price "<annual_price>" per year
    And I should see "<features>" included

    Examples:
      | plan       | monthly_price | annual_price | features                        |
      | Starter    | £9.99         | £99.99       | 5 projects, 1 user              |
      | Pro        | £29.99        | £299.99      | 50 projects, 5 users            |
      | Business   | £99.99        | £999.99      | Unlimited projects, 25 users    |
      | Enterprise | Custom        | Custom       | Unlimited everything, SLA       |

  Scenario Outline: Discount codes apply correctly
    Given I am on the checkout page for the "<plan>" plan
    When I enter the discount code "<code>"
    Then the discount of "<discount>" should be applied
    And the final price should be "<final_price>"

    Examples:
      | plan    | code      | discount | final_price |
      | Pro     | SAVE10    | 10%      | £26.99      |
      | Pro     | SAVE20    | 20%      | £23.99      |
      | Pro     | FLAT5     | £5.00    | £24.99      |
      | Business| SAVE10    | 10%      | £89.99      |
      | Business| ENTERPRISE| 30%      | £69.99      |

Cucumber runs the scenario once for each row in the Examples table, substituting the placeholders. This is incredibly powerful for testing business rules with many variations.

Tags: Organising And Filtering Tests

Tags let you categorise scenarios and run specific subsets:

@authentication @critical
Feature: User Authentication

  @smoke @fast
  Scenario: Successful login
    # This runs in smoke test suite and fast test suite
    Given I am on the login page
    When I enter valid credentials
    Then I should be logged in

  @regression
  Scenario: Login with expired password
    # Only runs in full regression suite
    Given my password expired 30 days ago
    When I try to log in
    Then I should be prompted to change my password

  @wip
  Scenario: Two factor authentication
    # Work in progress excluded from CI
    Given I have 2FA enabled
    When I enter my password
    Then I should be prompted for my 2FA code

  @slow @external
  Scenario: Login via SSO
    # Marked as slow and requiring external services
    Given I click "Sign in with Google"
    When I authenticate with Google
    Then I should be logged in via SSO

Run specific tags from the command line:

# Run only smoke tests fast feedback
cucumber --tags @smoke

# Run everything except work in progress
cucumber --tags "not @wip"

# Run critical authentication tests
cucumber --tags "@critical and @authentication"

# Run fast tests that arent external
cucumber --tags "@fast and not @external"

I typically use these tag categories:

  • @smoke Quick sanity checks, run on every commit
  • @regression Full test suite, run before releases
  • @critical Must pass before any deployment
  • @slow Tests that take a while, run less frequently
  • @wip Work in progress, excluded from CI
  • @external Requires external services, may be flaky
  • @javascript Needs a real browser (not headless)

Hooks: Setup And Teardown

Hooks let you run code before or after scenarios:

# features/support/hooks.rb
# Global setup and teardown
# Keeps individual scenarios clean

# Run before every scenario
Before do
  # Clear any leftover data from previous tests
  # DatabaseCleaner handles this but belt and braces innit
  ActionMailer::Base.deliveries.clear
  
  # Reset any stubbed services
  WebMock.reset!
  
  # Set a consistent time for deterministic tests
  Timecop.freeze(Time.zone.parse('2026-04-08 10:00:00'))
end

# Run after every scenario
After do |scenario|
  # Unfreeze time
  Timecop.return
  
  # Take screenshot if scenario failed helps with debuging
  if scenario.failed?
    timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
    filename = "tmp/screenshots/failed_#{timestamp}.png"
    page.save_screenshot(filename)
    puts "Screenshot saved: #{filename}"
  end
end

# Run before scenarios tagged with @javascript
Before('@javascript') do
  # Use a real browser for JS tests
  Capybara.current_driver = :selenium_chrome_headless
end

After('@javascript') do
  # Reset to default driver
  Capybara.use_default_driver
end

# Run before scenarios tagged with @time_sensitive
Before('@time_sensitive') do |scenario|
  # Some tests need specific dates
  # Look for a comment in the scenario with the date
  if scenario.source.first.comments.any?
    date_comment = scenario.source.first.comments.find { |c| c.text =~ /date:/i }
    if date_comment
      date_string = date_comment.text.gsub(/.*date:\s*/i, '').strip
      Timecop.freeze(Time.zone.parse(date_string))
    end
  end
end

# Run before scenarios tagged with @email
Before('@email') do
  # Make sure email deliveries are cleared
  ActionMailer::Base.deliveries.clear
  
  # Use inline delivery for immediate assertions
  ActionMailer::Base.delivery_method = :test
end

# Special hook for scenarios that need external APIs
Before('@external_api') do
  # Check if we should use real APIs or mocks
  if ENV['USE_REAL_APIS'] != 'true'
    # Stub common external services
    stub_request(:any, /api.stripe.com/).to_return(
      status: 200,
      body: { success: true }.to_json
    )
    stub_request(:any, /api.sendgrid.com/).to_return(status: 202)
  end
end

Custom Parameter Types

For complex domain concepts, define custom parameter types:

# features/support/parameter_types.rb
# Custom types for domain specific concepts
# Makes step defs cleaner and more expressive

# Money amounts handles currency symbols and decimals
ParameterType(
  name: 'money',
  regexp: /£[\d,]+\.?\d*/,
  transformer: -> (amount) {
    # Strip the pound sign and parse as decimal
    BigDecimal(amount.gsub(/[£,]/, ''))
  }
)

# Dates in various formats
ParameterType(
  name: 'date',
  regexp: /\d{1,2}(?:st|nd|rd|th)? (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* \d{4}|today|tomorrow|yesterday|\d+ days? (?:ago|from now)/,
  transformer: -> (date_string) {
    case date_string.downcase
    when 'today' then Date.current
    when 'tomorrow' then Date.tomorrow
    when 'yesterday' then Date.yesterday
    when /^(\d+) days? ago$/
      $1.to_i.days.ago.to_date
    when /^(\d+) days? from now$/
      $1.to_i.days.from_now.to_date
    else
      Date.parse(date_string)
    end
  }
)

# Order statuses validates against allowed values
ParameterType(
  name: 'order_status',
  regexp: /pending|confirmed|shipped|delivered|cancelled|refunded/,
  transformer: -> (status) { status.to_sym }
)

# User roles
ParameterType(
  name: 'user_role',
  regexp: /admin|manager|editor|viewer|customer|guest/,
  transformer: -> (role) { role.to_sym }
)

Now you can write cleaner steps:

Scenario: Applying a large discount
  Given the product costs £1,299.99
  When I apply a discount of £200.00
  Then the final price should be £1,099.99

Scenario: Order shipped notification
  Given an order was placed on 1st April 2026
  And the order status is "shipped"
  When the customer views their order
  Then they should see "Shipped on 1st April 2026"

Scenario: Admin access
  Given I am logged in as an admin user
  Then I should see the admin menu

Page Objects: Keeping Step Definitions Maintainable

For complex pages, use the Page Object pattern to encapsulate selectors:

# features/support/pages/checkout_page.rb
# Encapsulates all the checkout page interacitons
# If the UI changes, only update this file

class CheckoutPage
  include Capybara::DSL

  def visit_page
    visit checkout_path
  end

  def fill_shipping_address(address)
    within '#shipping-address' do
      fill_in 'Address line 1', with: address[:line1]
      fill_in 'Address line 2', with: address[:line2] if address[:line2]
      fill_in 'City', with: address[:city]
      fill_in 'Postcode', with: address[:postcode]
      select address[:country], from: 'Country'
    end
  end

  def fill_payment_details(card)
    within '#payment-details' do
      fill_in 'Card number', with: card[:number]
      fill_in 'Expiry', with: card[:expiry]
      fill_in 'CVC', with: card[:cvc]
      fill_in 'Name on card', with: card[:name]
    end
  end

  def apply_discount_code(code)
    fill_in 'Discount code', with: code
    click_button 'Apply'
  end

  def place_order
    click_button 'Place Order'
  end

  def order_total
    # Parse the total from the page
    find('#order-total').text.gsub(/[^\d.]/, '').to_f
  end

  def has_discount_applied?
    has_css?('.discount-applied')
  end

  def has_error_message?(message)
    has_css?('.error-message', text: message)
  end

  def has_shipping_option?(option)
    has_css?('.shipping-option', text: option)
  end

  def select_shipping_option(option)
    within '.shipping-options' do
      choose option
    end
  end
end
# features/step_definitions/checkout_steps.rb
# Step defs use the page object
# Much cleaner than raw Capybara calls everywhere

def checkout_page
  @checkout_page ||= CheckoutPage.new
end

Given('I am on the checkout page') do
  checkout_page.visit_page
end

When('I fill in my shipping address') do
  checkout_page.fill_shipping_address(
    line1: '123 Test Street',
    city: 'London',
    postcode: 'SW1A 1AA',
    country: 'United Kingdom'
  )
end

When('I fill in my card details') do
  checkout_page.fill_payment_details(
    number: '4242424242424242',
    expiry: '12/28',
    cvc: '123',
    name: 'Test User'
  )
end

When('I apply the discount code {string}') do |code|
  checkout_page.apply_discount_code(code)
end

When('I place my order') do
  checkout_page.place_order
end

Then('the order total should be {money}') do |expected_total|
  expect(checkout_page.order_total).to eq(expected_total)
end

Then('I should see the discount applied') do
  expect(checkout_page).to have_discount_applied
end

World Extensions: Sharing State Across Steps

Cucumber's World is where step definitions run. You can extend it with helper methods:

# features/support/world_extensions.rb
# Helpers available in all step definitions
# Keeps step defs clean and DRY

module AuthenticationHelpers
  def current_user
    @current_user
  end

  def login_as(user)
    @current_user = user
    visit login_path
    fill_in 'Email', with: user.email
    fill_in 'Password', with: 'password123'
    click_button 'Sign In'
  end

  def login_as_admin
    admin = create(:user, :admin)
    login_as(admin)
  end

  def logout
    click_link 'Log Out'
    @current_user = nil
  end
end

module MailHelpers
  def last_email
    ActionMailer::Base.deliveries.last
  end

  def emails_sent_to(address)
    ActionMailer::Base.deliveries.select { |e| e.to.include?(address) }
  end

  def clear_emails
    ActionMailer::Base.deliveries.clear
  end

  def email_should_contain(email, text)
    body = email.body.to_s
    expect(body).to include(text)
  end
end

module TimeHelpers
  def travel_to_date(date)
    Timecop.freeze(date)
  end

  def travel_forward(duration)
    Timecop.freeze(Time.current + duration)
  end

  def return_to_present
    Timecop.return
  end
end

# Include these modules in the Cucumber World
World(AuthenticationHelpers)
World(MailHelpers)
World(TimeHelpers)

Cucumber As Living Documentation

This is perhaps the most underrated benefit of BDD. Your feature files are not just tests, they are documentation that is always accurate because they are executed.

Consider this: how often is your project's documentation out of date? Requirements documents that were written months ago and never updated. Wiki pages that describe how things used to work. README files that are optimistic at best.

With Cucumber, the documentation is the test. If the feature file says clicking "Submit" sends an email, and the test passes, then clicking "Submit" definitely sends an email. The documentation cannot lie because it is verified automatically.

I structure feature files for readability:

# features/orders/order_lifecycle.feature
# This file documents the complete order lifecycle
# Read it to understand how orders work in this system

@orders @lifecycle
Feature: Order Lifecycle Management
  Orders in the system move through several states from creation to completion.
  This feature documents and verifies the valid state transitions.

  Background:
    Given a customer with email "[email protected]"
    And they have a verified payment method
    And there is a product "Widget Pro" priced at £49.99

  # ============================================
  # HAPPY PATH: Normal order completion
  # ============================================

  @happy_path
  Scenario: Complete order lifecycle
    # Customer places the order
    Given the customer adds "Widget Pro" to their cart
    When they complete the checkout process
    Then a new order should be created with status "pending"
    And the customer should receive an order confirmation email

    # Payment is processed
    When the payment is successfully processed
    Then the order status should change to "confirmed"
    And the inventory for "Widget Pro" should decrease by 1

    # Order is shipped
    When the warehouse ships the order
    Then the order status should change to "shipped"
    And the customer should receive a shipping notification email
    And a tracking number should be generated

    # Order is delivered
    When the courier marks the order as delivered
    Then the order status should change to "delivered"
    And the customer should receive a delivery confirmation email

  # ============================================
  # PAYMENT FAILURES
  # ============================================

  @payment @failure
  Scenario: Payment fails insufficient funds
    Given the customer adds "Widget Pro" to their cart
    And they complete the checkout process
    When the payment fails due to insufficient funds
    Then the order status should remain "pending"
    And the customer should receive a payment failed email
    And the inventory should not be affected

  @payment @failure
  Scenario: Payment fails card declined
    Given the customer adds "Widget Pro" to their cart
    And they complete the checkout process
    When the payment is declined by the bank
    Then the order should be marked as "payment_failed"
    And the customer should be prompted to try a different card

  # ============================================
  # CANCELLATIONS
  # ============================================

  @cancellation
  Scenario: Customer cancels before shipping
    Given the customer has a confirmed order
    When they request a cancellation
    Then the order should be cancelled
    And a full refund should be issued
    And the inventory should be restored

  @cancellation
  Scenario: Customer cannot cancel after shipping
    Given the customer has a shipped order
    When they try to cancel the order
    Then the cancellation should be rejected
    And they should be directed to the returns process

  # ============================================
  # REFUNDS AND RETURNS
  # ============================================

  @returns
  Scenario: Customer returns delivered item
    Given the customer has a delivered order
    And they are within the 30 day return window
    When they initiate a return
    Then a return label should be generated
    And the order status should change to "return_initiated"

    When the returned item is received at the warehouse
    Then the order status should change to "returned"
    And a full refund should be issued
    And the inventory should be restored

  @returns @partial
  Scenario: Partial refund for damaged item
    Given the customer has a delivered order
    And they report the item as damaged
    When customer support approves a 50% refund
    Then a partial refund of £24.99 should be issued
    And the order should be marked as "partially_refunded"
    And the damage should be recorded for quality tracking

A new developer joining the project can read this file and understand exactly how orders work. They do not need to dig through code or ask questions. The documentation is comprehensive, accurate, and always up to date.

Setting Up Cucumber In A Rails Project

Let me walk you through setting up Cucumber properly:

# Gemfile
# Add these to your test group

group :test do
  gem 'cucumber-rails', require: false
  gem 'database_cleaner-active_record'
  gem 'capybara'
  gem 'selenium-webdriver'
  gem 'webdrivers'  # auto manages browser drivers
  gem 'factory_bot_rails'
  gem 'faker'
  gem 'timecop'
  gem 'webmock'
  gem 'shoulda-matchers'
end

Generate the Cucumber structure:

rails generate cucumber:install

Configure the support files:

# features/support/env.rb
# Main Cucumber environment config
# Sets up all the things we need for testing

require 'cucumber/rails'
require 'capybara/rails'
require 'capybara/cucumber'

# Configure Capybara
Capybara.default_driver = :rack_test  # fast, no JS
Capybara.javascript_driver = :selenium_chrome_headless
Capybara.default_max_wait_time = 5  # seconds

# Configure Chrome for headless testing
Capybara.register_driver :selenium_chrome_headless do |app|
  options = Selenium::WebDriver::Chrome::Options.new
  options.add_argument('--headless')
  options.add_argument('--no-sandbox')
  options.add_argument('--disable-gpu')
  options.add_argument('--window-size=1920,1080')
  
  Capybara::Selenium::Driver.new(app, browser: :chrome, options: options)
end

# Database cleaner keeps test database clean between scenarios
DatabaseCleaner.strategy = :truncation
Cucumber::Rails::Database.javascript_strategy = :truncation

# Include Factory Bot methods
World(FactoryBot::Syntax::Methods)

# Stub external services by default
require 'webmock/cucumber'
WebMock.disable_net_connect!(allow_localhost: true)
# features/support/database_cleaner.rb
# Make sure we start each scenario with clean data

Before do
  DatabaseCleaner.start
end

After do
  DatabaseCleaner.clean
end

Run your features:

# Run all features
bundle exec cucumber

# Run specific feature file
bundle exec cucumber features/orders/checkout.feature

# Run specific scenario by line number
bundle exec cucumber features/orders/checkout.feature:42

# Run with tags
bundle exec cucumber --tags @smoke
bundle exec cucumber --tags "@orders and not @slow"

# Run with verbose output
bundle exec cucumber --format pretty

# Generate HTML report
bundle exec cucumber --format html --out reports/cucumber.html

Common Mistakes And How To Avoid Them

After years of using Cucumber, I have seen every mistake in the book. Here are the big ones:

Mistake 1: Testing Implementation, Not Behaviour

Bad:

Scenario: Create user record
  When I POST to /api/users with JSON {"name": "Dave"}
  Then the response status should be 201
  And the users table should have 1 row

Good:

Scenario: Registering a new account
  Given I am on the registration page
  When I register with the name "Dave"
  Then I should see "Welcome, Dave!"
  And I should receive a welcome email

The first example tests technical implementation. If you change the API structure, the test breaks even though the behaviour is the same. The second example tests what matters to the user.

Mistake 2: Overly Specific Steps

Bad:

When I click on the button with id "submit-form" and class "btn-primary"

Good:

When I submit the form

Do not tie steps to CSS selectors. If the design changes, all your tests break.

Mistake 3: Too Many Scenarios In One File

A feature file with 50 scenarios is hard to navigate and maintain. Break them up:

features/
  orders/
    placing_orders.feature
    order_cancellation.feature
    order_refunds.feature
    order_notifications.feature
  users/
    registration.feature
    authentication.feature
    password_reset.feature
    profile_management.feature

Mistake 4: Not Using Background

If every scenario starts with the same 5 steps, use a Background.

Mistake 5: Giant Step Definitions

Bad:

When('I complete the entire checkout process') do
  # 100 lines of code doing everything
end

Good:

When('I complete the checkout process') do
  step 'I fill in my shipping address'
  step 'I select standard shipping'
  step 'I fill in my card details'
  step 'I accept the terms and conditions'
  step 'I place my order'
end

Compose complex steps from simpler ones.

The Business Value: Why This Is Non Negotiable

Let me be blunt about why I refuse to work on projects without Cucumber or equivalent BDD tooling.

Clarity of requirements: Before anyone writes a line of code, the behaviour is specified precisely. "I thought you meant..." becomes impossible.

Reduced rework: When the test is the specification, there is no gap between what was requested and what was built. Features work correctly the first time.

Fearless refactoring: With comprehensive BDD coverage, I can restructure code confidently. The tests tell me immediately if something breaks.

Living documentation: New team members understand the system by reading feature files. No stale wikis or outdated diagrams.

Stakeholder communication: Product managers, designers, and even executives can read and contribute to feature files. Everyone speaks the same language.

Regression prevention: Every bug fix includes a scenario that would have caught it. Bugs do not come back.

Deployment confidence: When the Cucumber suite passes, the software works. Deployments become routine rather than stressful.

The projects I mentioned earlier, British Army, Volvo Group, Stint.co, Regios.at, Icebreaker.com, they all share this foundation. Complex systems with high stakes and zero tolerance for "it works on my machine." Cucumber makes them reliable.

Getting Started: Your First Feature

If you are new to Cucumber, start small:

  1. Pick one user journey, login, checkout, or whatever is core to your application
  2. Write the feature file in plain English first, do not worry about step definitions yet
  3. Show it to a non technical stakeholder and ask if it makes sense
  4. Implement the step definitions one at a time
  5. Run the feature and watch it pass
  6. Expand to more features

Within a week, you will wonder how you ever built software without it.

I have been using Cucumber for over a decade across dozens of projects. The examples in this post are based on real patterns from production applications. The spelling mistakes in code comments are intentional, because that is how real developers write comments.

Need help establishing BDD practices on your project? Or migrating a legacy codebase to Cucumber? I have done it countless times and can help you do it right. Let's talk.

Ready to transform how you build software? I can help you implement Cucumber and BDD practices that will make your projects more reliable, your documentation always accurate, and your deployments stress free. Get in touch.

Cucumber BDD: The Secret Weapon Behind Every Successful Project I Deliver - Georg Keferböck