Skip to main content
Back to Blog
BDD
2026-05-10

Cucumber Ruby BDD: Complete Guide 2026

Complete guide to Cucumber Ruby BDD in 2026. Setup with Rails, feature files, step definitions, Capybara browser integration, hooks, tags, parallel execution, reporting, and CI integration.

Cucumber Ruby BDD: Complete Guide 2026

Cucumber's origin story is in Ruby: Aslak Hellesoy released the first Cucumber gem in 2008 as a Ruby-only framework before porting it to other languages. In 2026, Cucumber Ruby remains a vital tool for Ruby on Rails teams who want executable acceptance tests, and the gem continues to be actively maintained. Combined with Capybara for browser automation, RSpec for assertions, and parallel_tests for parallel CI execution, Cucumber Ruby produces some of the most readable BDD suites in any language.

This guide is a complete walkthrough for adopting Cucumber Ruby on a Rails 8 project in 2026. We cover gem installation, project structure, feature files, step definitions, Capybara integration, hooks, tags, parallel execution, Allure reporting, CI integration, and the common gotchas that derail Cucumber Ruby adoption. Every example is current with Cucumber 10.0, Capybara 3.40, and Rails 8.0.

By the end you will have a production-ready BDD setup for a Rails app that scales to hundreds of scenarios with parallel CI, custom reporters, and proper isolation.

Key Takeaways

  • Cucumber Ruby is the original -- the most mature implementation of the framework.
  • Rails generators integrate seamlessly -- rails g cucumber:install scaffolds everything.
  • Capybara is the standard browser layer for Cucumber Ruby UI scenarios.
  • parallel_tests handles parallel execution across multiple processes.
  • RSpec matchers can be used directly in step definitions.

1. Installation

In a Rails 8 Gemfile:

group :test do
  gem 'cucumber-rails', '~> 3.0', require: false
  gem 'capybara', '~> 3.40'
  gem 'selenium-webdriver', '~> 4.20'
  gem 'database_cleaner-active_record'
  gem 'rspec-expectations'
  gem 'parallel_tests'
  gem 'allure-cucumber'
end

Install and scaffold:

bundle install
rails generate cucumber:install

This creates:

features/
  step_definitions/
  support/
    env.rb
    hooks.rb
config/
  cucumber.yml

2. cucumber.yml

<%
rerun = File.file?('rerun.txt') ? IO.read('rerun.txt') : ''
rerun_opts = rerun.to_s.strip.empty? ? '--tags ~@wip' : "--format rerun --out rerun.txt #{rerun}"
std_opts = "--format pretty --strict --tags 'not @wip'"
%>
default: <%= std_opts %> features
wip: --tags @wip:3 --wip features
rerun: <%= rerun_opts %> --format pretty
ci: --format pretty --format json --out reports/cucumber.json --tags 'not @manual'
smoke: --tags @smoke --format progress

3. Your First Feature File

# features/users/signin.feature
Feature: User signs in

  Background:
    Given a user exists with email "alice@example.com" and password "Sup3rS3cret!"

  @smoke @auth
  Scenario: Successful sign-in
    When I visit the sign-in page
    And I fill in "Email" with "alice@example.com"
    And I fill in "Password" with "Sup3rS3cret!"
    And I press "Sign in"
    Then I should see "Welcome, Alice"
    And I should be on the dashboard page

  Scenario Outline: Sign-in fails
    When I visit the sign-in page
    And I fill in "Email" with "<email>"
    And I fill in "Password" with "<password>"
    And I press "Sign in"
    Then I should see "<error>"

    Examples:
      | email             | password    | error                |
      | not-an-email      | Sup3rS3cret!| Invalid email        |
      | alice@example.com | wrong       | Invalid credentials  |

4. Step Definitions

# features/step_definitions/auth_steps.rb
Given('a user exists with email {string} and password {string}') do |email, password|
  @user = FactoryBot.create(:user, email: email, password: password, name: 'Alice')
end

When('I visit the sign-in page') do
  visit '/signin'
end

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

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

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

Then('I should be on the {string} page') do |page_name|
  expected_path = case page_name
                  when 'dashboard' then '/dashboard'
                  when 'sign-in' then '/signin'
                  else raise "Unknown page #{page_name}"
                  end
  expect(current_path).to eq(expected_path)
end

5. Capybara Configuration

# features/support/env.rb
require 'cucumber/rails'
require 'capybara/cucumber'
require 'capybara/rails'
require 'selenium-webdriver'

Capybara.javascript_driver = :selenium_chrome_headless
Capybara.default_max_wait_time = 5
Capybara.app_host = ENV.fetch('BASE_URL', 'http://localhost:3000')

Capybara.register_driver :selenium_chrome_headless do |app|
  options = Selenium::WebDriver::Chrome::Options.new
  options.add_argument('--headless=new')
  options.add_argument('--no-sandbox')
  options.add_argument('--window-size=1280,720')
  Capybara::Selenium::Driver.new(app, browser: :chrome, options: options)
end

6. Hooks

# features/support/hooks.rb
require 'database_cleaner/active_record'
DatabaseCleaner.strategy = :truncation

Before do
  DatabaseCleaner.start
end

After do |scenario|
  if scenario.failed?
    Capybara.page.save_screenshot("reports/screenshots/#{scenario.name.gsub(/\W+/, '_')}.png")
  end
  DatabaseCleaner.clean
end

Before('@requires-stripe') do
  WebMock.allow_net_connect!(allow: %w[stripe.com])
end

BeforeAll do
  Rails.application.eager_load!
end

AfterAll do
  Rails.cache.clear
end

7. Tags

Common conventions:

TagPurpose
@smokeCritical scenarios
@regressionFull suite
@javascriptRequires Selenium
@apiAPI-only
@wipIn progress
@manualManual testing

Run filtered:

bundle exec cucumber --tags '@smoke and not @wip'

8. Data Tables

Scenario: Bulk role assignment
  Given the following users:
    | name    | email             | role  |
    | Alice   | alice@example.com | admin |
    | Bob     | bob@example.com   | user  |
    | Charlie | carol@example.com | guest |
  Then 3 users should exist
Given('the following users:') do |table|
  table.hashes.each do |row|
    FactoryBot.create(:user, **row.symbolize_keys)
  end
end

Then('{int} users should exist') do |count|
  expect(User.count).to eq(count)
end

9. Parallel Execution with parallel_tests

bundle exec parallel_test features/ --type cucumber -n 4

Configure per-process databases in config/database.yml:

test:
  database: myapp_test<%= ENV['TEST_ENV_NUMBER'] %>

Then prepare them:

bundle exec rake parallel:create parallel:migrate

10. Allure Reporting

# features/support/allure.rb
require 'allure-cucumber'

AllureCucumber.configure do |c|
  c.results_directory = 'reports/allure-results'
  c.clean_results_directory = true
end

Generate report:

bundle exec cucumber --format AllureCucumber::CucumberFormatter
allure serve reports/allure-results

11. CI Integration

name: Cucumber
on: [push]
jobs:
  cucumber:
    runs-on: ubuntu-22.04
    services:
      postgres:
        image: postgres:16
        env: { POSTGRES_PASSWORD: postgres }
        ports: [5432:5432]
    strategy:
      matrix:
        shard: [1, 2, 3, 4]
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with: { ruby-version: 3.3, bundler-cache: true }
      - run: bundle exec rake db:create db:migrate
      - run: bundle exec parallel_test features/ --type cucumber -n 2 --group-by scenarios --only-group ${{ matrix.shard }}/4
        env:
          RAILS_ENV: test
          DATABASE_URL: postgres://postgres:postgres@localhost:5432/myapp_test
      - uses: actions/upload-artifact@v4
        if: always()
        with: { name: cucumber-report-${{ matrix.shard }}, path: reports/ }

12. Best Practices

  • Use FactoryBot for test data, not raw .new.
  • Use DatabaseCleaner truncation for Selenium/Capybara, transactions for non-JS scenarios.
  • Keep step definitions in step_definitions/ by domain.
  • Tag every scenario for filterability.
  • Run smoke on every PR, full regression nightly.

13. AI-Assisted Authoring

The cucumber-ruby SKILL.md pack on QASkills teaches Claude or Cursor to generate Capybara-driven step definitions matching Rails conventions. See cursor-skills-md-best-practices.

14. Advanced Capybara Patterns

Page Objects in Ruby

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

  def visit_page
    visit '/checkout'
  end

  def fill_card(number:, expiry:, cvv:)
    fill_in 'Card number', with: number
    fill_in 'Expiry', with: expiry
    fill_in 'CVV', with: cvv
  end

  def confirm
    click_button 'Confirm order'
  end
end

# In step definition
When('Alice completes checkout with card {string}') do |card|
  page = CheckoutPage.new
  page.visit_page
  page.fill_card(number: card, expiry: '12/30', cvv: '123')
  page.confirm
end

Wait Strategies

Capybara has automatic waiting, but explicit waits help:

Capybara.default_max_wait_time = 5

# in step definition
expect(page).to have_content('Order confirmed', wait: 10)
expect(page).not_to have_css('.spinner', wait: 5)

Headed Mode for Debugging

Capybara.javascript_driver = :selenium_chrome  # not headless

Or via env var:

HEADED=true bundle exec cucumber features/checkout.feature

Visiting External URLs

# config in env.rb
Capybara.app_host = nil  # disable automatic host prefix
Capybara.run_server = false

# in step
visit 'https://external-api.com/widget'

File Uploads

attach_file('Document', Rails.root.join('spec/fixtures/sample.pdf'))

15. Async Steps with Sidekiq

For Rails apps using Sidekiq, run jobs synchronously in tests:

# features/support/sidekiq.rb
require 'sidekiq/testing'
Sidekiq::Testing.inline!

This makes Sidekiq::Worker.perform_async run synchronously, simplifying scenarios that depend on background jobs.

16. Email Testing

Then('a welcome email should be sent to {string}') do |email|
  delivery = ActionMailer::Base.deliveries.find { |d| d.to.include?(email) }
  expect(delivery).not_to be_nil
  expect(delivery.subject).to eq('Welcome to our app')
end

17. Capybara Drivers Comparison

DriverSpeedJS supportUse case
:rack_testFastestNoAPI-only tests
:selenium_chromeFastYesStandard E2E
:selenium_chrome_headlessFastYesCI standard
:playwrightFastYesNew projects
:webkitDeprecatedYesLegacy

Most teams in 2026 default to :selenium_chrome_headless for CI and :playwright for new projects.

18. Rails-Specific Patterns

Devise integration

Given('a logged-in admin') do
  @user = FactoryBot.create(:admin)
  login_as(@user, scope: :user)
end

Rails Cache Clearing

Before do
  Rails.cache.clear
end

Mailcatcher Integration

For testing email in Capybara, configure ActionMailer to use SMTP localhost:1025 (Mailcatcher).

19. Frequently Asked Questions

Q: Can I use Cucumber Ruby with non-Rails apps? A: Yes -- cucumber-ruby works standalone. Remove cucumber-rails and configure Capybara directly.

Q: Should I use Cucumber or RSpec? A: For unit tests, RSpec. For acceptance tests with stakeholder readability, Cucumber. They coexist well.

Q: How do I avoid flaky tests in Capybara? A: Use Capybara's auto-waiting (have_content, have_css). Avoid sleep. Use cy.intercept-style waiting via webmock for API stubs.

Q: AI agents for Cucumber Ruby? A: Yes -- the cucumber-ruby SKILL.md pack on QASkills teaches Claude to generate step definitions using Capybara conventions.

Q: Can I run tests in parallel? A: Yes -- use parallel_tests with per-process databases.

Conclusion

Cucumber Ruby + Capybara remains an excellent BDD stack for Rails teams in 2026. The maturity of the gems, the depth of conventions, and the seamless Rails integration make it a low-friction adoption. For broader BDD strategy see comparing-popular-bdd-frameworks-2026-complete-guide.

Cucumber Ruby BDD: Complete Guide 2026 | QASkills.sh