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:
- Business person writes requirements document
- Developer reads document, interprets it their own way
- Developer writes code based on their interpretation
- Tester tests based on their interpretation of the same document
- Business person sees the result and says "that is not what I meant"
- Everyone argues about whose interpretation was correct
- Expensive rework happens
- 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:
@smokeQuick sanity checks, run on every commit@regressionFull test suite, run before releases@criticalMust pass before any deployment@slowTests that take a while, run less frequently@wipWork in progress, excluded from CI@externalRequires external services, may be flaky@javascriptNeeds 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:
- Pick one user journey, login, checkout, or whatever is core to your application
- Write the feature file in plain English first, do not worry about step definitions yet
- Show it to a non technical stakeholder and ask if it makes sense
- Implement the step definitions one at a time
- Run the feature and watch it pass
- 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.