Cucumber and BDD: why I write the feature file before the code

TL;DR

Difficulty: Intermediate

Most rework starts with a requirement two people read two ways. BDD writes the behaviour down as concrete scenarios that are also tests.

  • Gherkin: Feature, Rule, Scenario, Given, When, Then, Background, Scenario Outline and Examples. Plain English with structure.
  • Step definitions turn the words into Capybara actions. Reuse helper methods, not steps calling steps.
  • Outlines, tags, hooks, parameter types and page objects keep a large suite readable.
  • Living documentation: a feature file that runs in every build stays current.
  • Setup in Rails: the Cucumber Rails integration, Capybara, factory_bot and Selenium 4.11 or later. No webdrivers gem, no second DatabaseCleaner hook.

I have used Cucumber on every project I build for more than 10 years, because it makes the agreement about what software should do explicit and checked. This is the long version of why, with Gherkin, step definitions, the advanced features, a Rails setup and the common mistakes, worked through on an invented Cork coffee shop and a Limerick SaaS.

The most important tool I use is not an editor or an AI

The single most important tool in the way I build software is not a clever editor, and it is not an AI code generator. It is Cucumber. I have used it for more than 10 years, and I use it on every project I build: public sector systems where a bug is dangerous rather than annoying, enterprise logistics, SaaS products that ship every week, marketplaces and online shops.

That is my practice, not a law of nature. Plenty of good software is built without Cucumber. But I have not found a better way to make sure that what gets built is what the business actually asked for, and this article is the long version of why. It covers what Behaviour Driven Development is, the Gherkin language, step definitions, the advanced features, setting it up in Rails, and the mistakes I see most.

What BDD is

The way a lot of software still gets built goes roughly like this:

  1. A business person writes a requirements document.
  2. A developer reads it and interprets it one way.
  3. The developer writes code based on that interpretation.
  4. A tester tests it based on their own interpretation of the same document.
  5. The business person sees the result and says "that is not what I meant".
  6. Everyone argues about whose reading was correct, and the rework begins.

Nobody in that chain is careless. The document is simply ambiguous, and three people read it three ways.

Behaviour Driven Development (BDD) fixes this by making everyone write down the behaviour together, in a structured form that is also an executable test. Dan North described it in 2006, as a way of explaining test driven development in terms of behaviour rather than tests.1 I cover how it fits next to unit testing in TDD and BDD in Ruby on Rails.

The key idea: if the specification is the test, the gap between what was meant and what was built gets very small. The software either behaves as specified or the test goes red.

Worked example: one sentence, two readings

Bantry Bean Co. is an invented coffee subscription shop in Cork, at bantrybean.example. Its owner writes: "Damaged items get a 50% refund." The Roaster's Selection box costs €49.99.

The developer reads it as 50% of the price, rounded down, so €24.99. The owner, asked later, expected €25.00, because "half of fifty is twenty five". Neither is wrong. The sentence did not say. Written as a scenario, the number has to be on the page before anyone writes code:

Scenario: Half refund for a damaged bag
  Given a delivered order for "Roaster's Selection" at €49.99
  When customer support approves a 50% refund for damage
  Then a refund of €25.00 should be issued

Half of €49.99 is €24.995. The scenario forces the decision, round half up, and records it where both people can read it.

What Cucumber does

Cucumber is the tool that makes BDD practical. You write specifications in Gherkin, a small language that reads like plain English but has enough structure to be parsed by a computer and run as tests.2

Here is a feature file:

# features/user_login.feature
Feature: User login
  As a registered customer
  I want to sign in to my account
  So that I can see my subscription

  Background:
    Given a customer exists with email "sarah@example.com" and password "correcthorse"

  Scenario: Successful sign in with valid details
    Given I am on the sign in page
    When I fill in "Email" with "sarah@example.com"
    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 "account" page

  Scenario: Failed sign in with the wrong password
    Given I am on the sign in page
    When I fill in "Email" with "sarah@example.com"
    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 "sign in" page

Read it out loud. The product owner can follow it, so can the managing director, so can the developer who joins next year. That is the point.

And it is not only documentation. When I run bundle exec cucumber features/user_login.feature, Cucumber drives the application through Capybara: it visits the page, fills in the fields, presses the button and checks the result.3 If the application does not behave as specified, the test fails.

The Gherkin language, the essentials

Gherkin has a small vocabulary. The primary keywords are Feature, Rule, Scenario (or Example), Given, When, Then, And, But, Background, Scenario Outline and Examples.2

Feature

Every .feature file starts with a Feature and a short description of the capability:

Feature: Basket
  As an online shopper
  I want to manage the items in my basket
  So that I can buy several products in one order

The "As a, I want, So that" lines are a convention, not syntax. Gherkin treats them as free text.2 I keep them anyway, because they make you say who the feature is for and why it exists. If you cannot fill in those three lines, you do not understand the feature well enough to build it.

Scenario

A scenario is one concrete example of one behaviour:

Scenario: Adding a product to an empty basket
  Given my basket is empty
  When I add "House Blend 1kg" to my basket
  Then my basket should contain 1 item
  And the basket total should be €18.50

A good scenario is independent (it does not rely on another scenario running first), focused (one behaviour) and clear (anyone can tell what is being checked).

The step keywords

Given sets up the context, the state of the world before the action. When is the action, what the person does. Then is the outcome you expect.2 And and But continue whichever kind of step came before, and But simply reads better for something that should not happen:

Scenario: Discounted order with free delivery
  Given I am signed in as "regular@example.com"
  And I have the discount code "SAVE20"
  And "Espresso Gift Box" is priced at €39.95
  When I add "Espresso Gift Box" to my basket
  And I apply the discount code
  And I go to the checkout
  Then I should see the discounted price of €31.96
  And I should see free delivery applied
  But I should not see the express delivery option

Background

When every scenario in a file shares the same setup, a Background runs before each one:2

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

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

  Scenario: Deleting a product
    Given a product "Old Blend" exists
    When I click "Delete" next to "Old Blend"
    And I confirm the deletion
    Then "Old Blend" should not appear in the product list

Step definitions turn the words into actions

Feature files say what should happen. Step definitions say how, in Ruby. The words in quotes become arguments through Cucumber Expressions such as {string} and {int}.

# features/step_definitions/authentication_steps.rb
Given('I am signed in as {string}') do |email|
  @current_user = User.find_by(email: email) || create(:user, email: email)
  sign_in_through_the_form(@current_user)
end

Given('I am signed in as an admin') do
  @current_user = create(:user, :admin)
  sign_in_through_the_form(@current_user)
end

When('I sign out') do
  click_button 'Sign out'
end

Then('I should be signed in') do
  expect(page).to have_button('Sign out')
end

create comes from factory_bot, and Cucumber only sees it once World(FactoryBot::Syntax::Methods) is in a file under features/support.4 visit, fill_in, click_button and expect(page) come from Capybara.3

Notice what the admin step does not do. An older version of this article called one step from another with step %(...). Cucumber's own guidance says that is possible in Cucumber Ruby but not recommended: extract a helper method and call it from both steps instead.5 That is what sign_in_through_the_form is.

A small set of generic steps covers most pages:

# features/step_definitions/navigation_steps.rb
PAGES = {
  'home' => -> { root_path },
  'sign in' => -> { new_user_session_path },
  'account' => -> { account_path },
  'basket' => -> { cart_path },
  'checkout' => -> { checkout_path }
}.freeze

def path_for(name)
  route = PAGES.fetch(name.downcase) { raise "No path for '#{name}', add it to PAGES in navigation_steps.rb" }
  instance_exec(&route)
end

Given('I am on the {string} page') { |name| visit path_for(name) }
Given('I am on the sign in page') { visit path_for('sign in') }
Then('I should be on the {string} page') { |name| expect(page).to have_current_path(path_for(name)) }
Then('I should still be on the {string} page') { |name| expect(page).to have_current_path(path_for(name)) }

# features/step_definitions/form_steps.rb
When('I fill in {string} with {string}') { |field, value| fill_in field, with: value }
When('I select {string} from {string}') { |value, field| select value, from: field }
When('I check {string}') { |box| check box }
When('I press {string}') { |button| click_button button }
When('I click {string}') { |label| click_on label } # a link or a button

# features/step_definitions/assertion_steps.rb
Then('I should see {string}') { |text| expect(page).to have_content(text) }
Then('I should not see {string}') { |text| expect(page).not_to have_content(text) }
Then('I should see {string} within {string}') do |text, selector|
  within(selector) { expect(page).to have_content(text) }
end

One map of page names, used by both the "on" and the "be on" steps, so they can never drift apart. have_current_path waits and retries, where comparing current_path directly does not, which matters as soon as JavaScript is involved. And Capybara's click_on already clicks a link or a button, so there is no need to catch one exception and try the other.3

The advanced features I use

Scenario outlines, for business rules with many cases

When the same behaviour needs checking with different inputs, a Scenario Outline runs once for each row of its Examples table, filling in the placeholders.2

Feature: Subscription pricing
  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 final price should be <final_price>

    Examples:
      | plan     | code       | final_price |
      | Pro      | SAVE10     | €26.99      |
      | Pro      | SAVE20     | €23.99      |
      | Pro      | FLAT5      | €24.99      |
      | Business | SAVE10     | €89.99      |
      | Business | ENTERPRISE | €69.99      |

Worked example: checking the pricing table by hand

Curragower is an invented shift planning SaaS in Limerick, at curragower.example. Pro costs €29.99 a month and Business €99.99. Before the table goes into the feature file, the owner and I check every row:

  • Pro with SAVE10: 29.99×0.9=26.99129.99 \times 0.9 = 26.991, so €26.99.
  • Pro with SAVE20: 29.99×0.8=23.99229.99 \times 0.8 = 23.992, so €23.99.
  • Pro with FLAT5: 29.99−5=24.9929.99 - 5 = 24.99, so €24.99.
  • Business with SAVE10: 99.99×0.9=89.99199.99 \times 0.9 = 89.991, so €89.99.
  • Business with ENTERPRISE (30% off): 99.99×0.7=69.99399.99 \times 0.7 = 69.993, so €69.99.

Every percentage row rounds down here, but only because the third decimal happens to be small. With the refund example earlier, €24.995 rounded half up to €25.00. The table and the refund scenario together pin down one rounding rule for the whole product: to the nearest cent, half up.

Tags, to organise and filter

Tags group scenarios so you can run a subset. Cucumber filters them with tag expressions, which combine tags with and, or and not.6

@authentication @critical
Feature: Customer authentication

  @smoke
  Scenario: Successful sign in
    Given I am on the sign in page
    When I sign in with valid details
    Then I should be signed in

  @wip
  Scenario: Two factor authentication
    Given I have two factor authentication turned on
    When I enter my password
    Then I should be asked for my code

  @javascript @external
  Scenario: Sign in with Google
    Given I am on the sign in page
    When I sign in with Google
    Then I should be signed in
bundle exec cucumber --tags @smoke
bundle exec cucumber --tags "not @wip"
bundle exec cucumber --tags "@critical and @authentication"
bundle exec cucumber --tags "@javascript and not @external"

The tags I typically use: @smoke for quick checks on every commit, @regression for the full suite before a release, @critical for what must pass before any deployment, @slow for long scenarios, @wip for work in progress kept out of continuous integration, and @external for anything that needs an outside service. @javascript is the one tag with built in meaning: Capybara switches that scenario to its JavaScript capable driver, which can perfectly well be headless.3

Hooks, for setup and teardown

Hooks run code before or after scenarios, optionally only for scenarios that match a tag expression.6

# features/support/hooks.rb
Before do
  ActionMailer::Base.deliveries.clear
  Timecop.freeze(Time.zone.parse('2026-04-08 10:00'))
end

After do |scenario|
  Timecop.return
  if scenario.failed? && Capybara.current_driver != :rack_test
    page.save_screenshot("tmp/screenshots/#{scenario.name.parameterize}.png")
  end
end

Before('@external') do
  next if ENV['USE_REAL_APIS'] == 'true'

  stub_request(:post, %r{api\.stripe\.com}).to_return(status: 200, body: { id: 'pi_test' }.to_json)
end

The screenshot only runs when a real browser is driving, because the default rack_test driver has no screen to capture. I removed two hooks from the older version: one that switched drivers for @javascript, which Capybara already does, and one that read dates out of scenario comments, which was cleverer than it was clear.

Custom parameter types, for your own domain

Cucumber lets you teach it the words of your domain, so a step can say {money} instead of a regular expression:

# features/support/parameter_types.rb
ParameterType(
  name: 'money',
  regexp: /€\d[\d,]*(?:\.\d{2})?/,
  transformer: ->(amount) { BigDecimal(amount.delete('€,')) }
)

ParameterType(
  name: 'order_state',
  regexp: /cart|address|delivery|payment|confirm|complete|canceled/,
  transformer: ->(state) { state }
)
Scenario: 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

Money becomes a BigDecimal, never a floating point number, because floats cannot hold most cents exactly. Compare it with a BigDecimal read from the page, not with to_f.

Page objects, so one change touches one file

For complex pages I wrap the selectors in a page object. When the design changes, one file changes, and the step definitions stay as they are.

# features/support/pages/checkout_page.rb
class CheckoutPage
  include Capybara::DSL

  def fill_address(address)
    within('#shipping-address') do
      fill_in 'Address line 1', with: address[:line1]
      fill_in 'Town or city', with: address[:city]
      fill_in 'County', with: address[:county]
      fill_in 'Eircode', with: address[:eircode]
    end
  end

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

  def place_order = click_button('Place order')

  def order_total
    BigDecimal(find('#order-total').text.delete('€,'))
  end
end

# features/step_definitions/checkout_steps.rb
def checkout_page = (@checkout_page ||= CheckoutPage.new)

When('I fill in my delivery address') do
  checkout_page.fill_address(line1: '12 MacCurtain Street', city: 'Cork', county: 'Co. Cork', eircode: 'T23 X7K4')
end

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

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

Helpers in the World

Cucumber Ruby runs every scenario in a fresh object it calls the World, and every step runs in that context, so you can mix your own modules into it.7 That is where helpers such as sign_in_through_the_form live:

# features/support/world_extensions.rb
module AuthenticationHelpers
  def sign_in_through_the_form(user, password: 'password123')
    visit new_user_session_path
    fill_in 'Email', with: user.email
    fill_in 'Password', with: password
    click_button 'Sign in'
    expect(page).to have_button('Sign out')
  end
end

module MailHelpers
  def emails_sent_to(address) = ActionMailer::Base.deliveries.select { |m| m.to.include?(address) }
end

World(AuthenticationHelpers, MailHelpers)

Feature files as living documentation

This is the most underrated benefit. How often is a project's documentation out of date? The requirements document from last spring, the wiki page that describes how things used to work, the README that was optimistic on the day it was written.

A feature file that runs in every build cannot drift far from the truth without someone noticing, because a passing suite means the behaviour it describes is there. It documents only what it covers, so it is not the whole truth, but what it says is current. I structure the important ones to be read:

# features/orders/order_lifecycle.feature
@orders
Feature: Order lifecycle
  Orders move from the basket to completion, and some come back.
  This file documents and checks the valid transitions.

  Background:
    Given a customer with email "customer@example.com"
    And a product "House Blend 1kg" priced at €18.50

  Rule: A paid order is confirmed and reduces stock

    Scenario: Normal order
      Given the customer has "House Blend 1kg" in the basket
      When they complete the checkout and the payment succeeds
      Then the order should be "complete"
      And the stock of "House Blend 1kg" should fall by 1
      And the customer should receive an order confirmation email

    Scenario: Card declined
      Given the customer has "House Blend 1kg" in the basket
      When they complete the checkout and the card is declined
      Then the order should still be at "payment"
      And the stock should not change

  Rule: Orders can be cancelled until they ship

    Scenario: Cancelled before dispatch
      Given the customer has a complete order that has not shipped
      When they cancel it
      Then a full refund of €18.50 should be issued
      And the stock should be restored

    Scenario: Too late to cancel
      Given the customer has an order that has shipped
      When they try to cancel it
      Then the cancellation should be refused
      And they should be pointed to the returns page

Rule, available since Gherkin 6, groups the scenarios that illustrate one business rule.2 A new developer can read this file and know how orders work without digging through the code.

Setting up Cucumber in a Rails project

# Gemfile
group :test do
  gem 'cucumber-rails', require: false
  gem 'database_cleaner-active_record'
  gem 'capybara'
  gem 'selenium-webdriver'
  gem 'factory_bot_rails'
  gem 'timecop'
  gem 'webmock'
end

The older version of this list included the webdrivers gem. Its own README now says that with Selenium 4.11 or later you should stop requiring it, because Selenium Manager finds and installs the browser driver for you.89

bundle install
bin/rails generate cucumber:install

The generator creates features/support/env.rb. I add only what the defaults do not cover:310

# features/support/env.rb (after the generated lines)
Capybara.default_driver = :rack_test                    # fast, no JavaScript
Capybara.javascript_driver = :selenium_chrome_headless  # registered by Capybara already
Capybara.default_max_wait_time = 5

Cucumber::Rails::Database.javascript_strategy = :truncation
World(FactoryBot::Syntax::Methods)

require 'webmock/cucumber'
WebMock.disable_net_connect!(allow_localhost: true)

There is no separate file calling DatabaseCleaner.start and DatabaseCleaner.clean. By default cucumber-rails runs both around every scenario itself, so doing it again only slows the suite down.10

bundle exec cucumber                                    # everything
bundle exec cucumber features/orders/order_lifecycle.feature:14  # one scenario, by line
bundle exec cucumber --format html --out reports/cucumber.html

The mistakes I see most

Testing the implementation, not the behaviour

# Not this
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

# This
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 breaks the moment the API changes, even when the behaviour is the same. The second checks what the customer cares about. API contracts deserve tests too, at their own level.

Steps tied to the markup

When I click on the button with id "submit-form" and class "btn-primary" breaks with every redesign. When I submit the form survives it. Keep selectors in step definitions and page objects, never in the feature file.

One file with 50 scenarios

Split by capability: features/orders/placing_orders.feature, features/orders/cancellation.feature, features/users/registration.feature and so on.

Giant steps, or steps calling steps

A step that hides 100 lines of code, or one that calls five other steps, is hard to debug. Compose from helper methods, as with sign_in_through_the_form.5

Worked example: rewriting one bad scenario

Bantry Bean's first draft for gift subscriptions read:

Scenario: Gift
  Given I click "#gift-toggle"
  And I fill in "gift_email" with "friend@example.com"
  When I press "Pay"
  Then the gift_subscriptions table should have 1 row

Three problems: a Given that is an action, selectors and field ids in the text, and a database check instead of an outcome a person sees. Rewritten:

Scenario: Sending a three month gift subscription
  Given I am buying the "3 month gift" subscription
  When I send it to "friend@example.com" and pay
  Then I should see "Your gift is on its way"
  And "friend@example.com" should receive a gift email

The owner can now read it, sign it off, and notice the thing nobody had asked: what the gift email should say.

Why I treat it as part of the job

I will not build a project without Cucumber or an equivalent BDD layer. That is my rule, and here is what it buys:

  • Clear requirements. The behaviour is written down precisely before the code, so "I thought you meant" mostly disappears.
  • Less rework. When the scenario is the specification, the gap between what was requested and what was built shrinks.
  • Refactoring without fear. With the important journeys covered, I can restructure code and know within minutes if something broke.
  • Documentation that stays current. New people read the feature files.
  • One language for everyone. Owners, designers and developers read and write the same scenarios.
  • Bugs that stay fixed. Every bug fix comes with a scenario that would have caught it.

None of it makes software perfect. It makes the agreement about what the software should do explicit, checked and visible, which is where most of the trouble I see starts.

Your first feature

  1. Pick one journey that matters, such as sign in or checkout.
  2. Write the feature file in plain English first. Do not think about step definitions yet.
  3. Show it to someone who does not write code and ask whether it says what they mean.
  4. Write the step definitions one at a time.
  5. Run it and watch it pass, then break the code on purpose and watch it fail.
  6. Add the next journey.

Within a few weeks you will notice the conversations change before the code does, and a good few arguments simply never happen.

Footnotes

  1. Dan North, Introducing BDD, first published in Better Software magazine, March 2006, https://dannorth.net/blog/introducing-bdd/

  2. Cucumber, Gherkin reference, https://cucumber.io/docs/gherkin/reference/

  3. Capybara, README, https://github.com/teamcapybara/capybara

  4. thoughtbot, factory_bot Getting Started (RSpec and Cucumber configuration), https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md

  5. Cucumber, Cucumber anti patterns, conjunction steps, https://cucumber.io/docs/guides/anti-patterns/

  6. Cucumber, API reference: tag expressions and hooks, https://cucumber.io/docs/cucumber/api/

  7. Cucumber, Sharing state between steps, https://cucumber.io/docs/cucumber/state/

  8. Titus Fortner, webdrivers README, update on the future of this project, https://github.com/titusfortner/webdrivers

  9. Selenium, Selenium Manager, Selenium documentation, https://www.selenium.dev/documentation/selenium_manager/

  10. Cucumber, cucumber-rails README, https://github.com/cucumber/cucumber-rails

If your requirements keep turning into rework, bring one feature you are about to build to the free workshop. We will write its scenarios together before any code exists, and the first two weeks of work are on me.