CrackedRuby CrackedRuby

Behavior-Driven Development (BDD)

Overview

Behavior-Driven Development (BDD) extends Test-Driven Development (TDD) by focusing on the behavior of an application from the end user's perspective. Rather than writing tests that verify implementation details, BDD emphasizes describing how software should behave in specific scenarios using domain language accessible to all project stakeholders.

BDD emerged from Dan North's work in 2003 while addressing recurring patterns in teaching TDD. Teams consistently struggled with knowing where to start testing, what to test, what not to test, and how to name their tests. BDD provides a structured approach to these questions by centering the conversation on behavior and business value.

The methodology operates at multiple levels. At the specification level, scenarios describe expected behaviors in plain language that business stakeholders can read and verify. At the implementation level, these scenarios become executable tests that verify the software matches the specified behavior. This dual nature makes BDD both a specification technique and a testing approach.

BDD introduces a ubiquitous language shared between developers, testers, business analysts, and domain experts. This language appears in both conversations and code, reducing translation errors and ensuring everyone discusses the same behaviors using consistent terminology.

Feature: User Authentication
  As a registered user
  I want to log into the system
  So that I can access my personal dashboard

  Scenario: Successful login with valid credentials
    Given I am on the login page
    And I have registered with email "user@example.com"
    When I enter email "user@example.com"
    And I enter password "secure_password"
    And I click the login button
    Then I should see my dashboard
    And I should see a welcome message

This scenario demonstrates BDD's natural language format. Non-technical stakeholders can read and understand what the system should do, while the scenario also serves as an executable test specification.

Key Principles

BDD operates on three core principles that distinguish it from other development methodologies.

Outside-In Development drives the development process starting from the user's perspective rather than from technical implementation details. Teams begin by describing how users interact with the system, then build the components necessary to support those interactions. This approach ensures every piece of code serves a clear user need.

The process typically flows from acceptance tests describing complete user workflows down to unit tests verifying individual component behaviors. Each outer test drives the creation of inner components, forming a chain from user behavior to implementation detail.

Ubiquitous Language establishes shared vocabulary between all project stakeholders. Technical team members and business experts use identical terms when discussing features, reducing misunderstandings and translation overhead. This language appears in conversations, specifications, and code.

The domain language evolves throughout the project as the team's understanding deepens. Terms become more precise, ambiguities get resolved through discussion, and the vocabulary grows to accommodate new concepts. Code reflects this language directly through class names, method names, and variable names that match business terminology.

Three Amigos Collaboration brings together three perspectives during specification workshops: business perspective (what value this provides), development perspective (how to build it), and testing perspective (what could go wrong). This collaboration typically occurs before implementation begins.

The three amigos discuss concrete examples of how features should work in specific situations. These examples become the foundation for executable specifications. The conversation uncovers edge cases, clarifies requirements, and identifies missing information before coding starts.

BDD scenarios follow a specific structure that separates context, action, and outcome:

# RSpec example showing BDD principles
RSpec.describe 'Shopping Cart' do
  describe 'adding items' do
    context 'when the cart is empty' do
      it 'increases the item count' do
        cart = ShoppingCart.new
        cart.add_item(Product.new(name: 'Book'))
        expect(cart.item_count).to eq(1)
      end
    end

    context 'when adding a duplicate item' do
      it 'increases the quantity instead of adding a new line' do
        cart = ShoppingCart.new
        product = Product.new(name: 'Book')
        cart.add_item(product)
        cart.add_item(product)
        expect(cart.line_items.count).to eq(1)
        expect(cart.line_items.first.quantity).to eq(2)
      end
    end
  end
end

Each specification describes a single behavior in a specific context. The test name reads as a sentence describing what happens, making the test suite serve as living documentation.

Discovery Through Examples uses concrete scenarios to explore and document system behavior. Abstract requirements become clear through specific examples that demonstrate how the system should respond to particular inputs.

Teams identify multiple examples for each feature, including typical cases, edge cases, and error conditions. These examples drive out hidden assumptions and reveal gaps in understanding. The conversation around examples often produces more value than the resulting specifications themselves.

Ruby Implementation

Ruby provides extensive tooling for BDD through frameworks that support both specification-style testing and natural language scenarios.

RSpec represents the dominant BDD framework in Ruby. It provides a domain-specific language for writing specifications that read like sentences describing behavior:

RSpec.describe Order do
  describe '#total' do
    context 'with standard items' do
      it 'sums the item prices' do
        order = Order.new
        order.add_item(price: 10.00)
        order.add_item(price: 15.00)
        expect(order.total).to eq(25.00)
      end
    end

    context 'with discounts applied' do
      it 'subtracts the discount from the subtotal' do
        order = Order.new
        order.add_item(price: 100.00)
        order.apply_discount(percentage: 10)
        expect(order.total).to eq(90.00)
      end

      it 'does not apply discounts below the minimum order value' do
        order = Order.new
        order.add_item(price: 5.00)
        order.apply_discount(percentage: 10, minimum: 20.00)
        expect(order.total).to eq(5.00)
      end
    end
  end
end

RSpec organizes specifications using describe and context blocks that establish the scenario, while it blocks state expected outcomes. This structure maps directly to BDD's emphasis on context and behavior.

The framework includes extensive matcher libraries for expressing expectations naturally:

# Collection matchers
expect(items).to include(target_item)
expect(cart.items).to all(be_a(Product))
expect(results).to contain_exactly(1, 2, 3)

# Change matchers
expect { cart.add_item(product) }.to change { cart.item_count }.by(1)
expect { account.withdraw(50) }.to change { account.balance }.from(100).to(50)

# Error matchers
expect { processor.execute }.to raise_error(ValidationError)
expect { parser.parse(invalid_data) }.to raise_error(ParseError, /invalid format/)

Cucumber brings natural language scenarios to Ruby applications. Business stakeholders write features in Gherkin syntax, and developers implement step definitions that connect the natural language to Ruby code:

# features/authentication.feature
Feature: User Login
  Scenario: Login with valid credentials
    Given a user exists with email "alice@example.com" and password "secret"
    When I visit the login page
    And I fill in "Email" with "alice@example.com"
    And I fill in "Password" with "secret"
    And I click "Sign In"
    Then I should be on my dashboard
    And I should see "Welcome back, Alice"

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

When('I visit the login page') do
  visit login_path
end

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

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

Then('I should be on my dashboard') do
  expect(current_path).to eq(dashboard_path)
end

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

Step definitions use regular expressions or Cucumber expressions to extract parameters from natural language steps. The same step definitions can be reused across multiple scenarios, building a vocabulary of actions the system supports.

Capybara integrates with both RSpec and Cucumber to provide browser automation for acceptance testing:

RSpec.feature 'Product Search' do
  scenario 'Finding products by category' do
    create(:product, name: 'Ruby Programming', category: 'Books')
    create(:product, name: 'Laptop Stand', category: 'Electronics')
    
    visit products_path
    select 'Books', from: 'Category'
    click_button 'Search'
    
    expect(page).to have_content('Ruby Programming')
    expect(page).not_to have_content('Laptop Stand')
  end

  scenario 'Viewing product details', js: true do
    product = create(:product, name: 'Wireless Mouse', description: 'Ergonomic design')
    
    visit products_path
    click_link 'Wireless Mouse'
    
    expect(page).to have_css('h1', text: 'Wireless Mouse')
    expect(page).to have_content('Ergonomic design')
  end
end

Capybara abstracts browser interaction details, allowing tests to focus on user behavior rather than HTML structure. The framework supports multiple drivers including Selenium, Cuprite, and Rack::Test.

FactoryBot provides test data generation aligned with BDD principles:

FactoryBot.define do
  factory :user do
    sequence(:email) { |n| "user#{n}@example.com" }
    password { 'password123' }
    
    trait :admin do
      role { :admin }
    end
    
    trait :with_orders do
      after(:create) do |user|
        create_list(:order, 3, user: user)
      end
    end
  end
end

# Usage in specs
RSpec.describe 'Admin Dashboard' do
  it 'displays user statistics' do
    create_list(:user, 5)
    create_list(:user, 2, :admin)
    admin = create(:user, :admin)
    
    sign_in(admin)
    visit admin_dashboard_path
    
    expect(page).to have_content('Total Users: 8')
  end
end

Implementation Approaches

Organizations adopt BDD through different strategies depending on team structure, project constraints, and existing practices.

Scenario-First Approach begins by writing Gherkin scenarios that capture business requirements in natural language before any code exists. Teams hold specification workshops with business stakeholders to develop these scenarios collaboratively.

This approach works well when business stakeholders actively participate in defining requirements and teams need strong alignment between business goals and technical implementation. The natural language format enables non-technical stakeholders to review and validate specifications directly.

Implementation flows from scenarios to step definitions to application code:

Feature: Order Processing
  Scenario: Applying bulk discount
    Given I have a customer account
    And my shopping cart contains:
      | Item      | Quantity | Price |
      | Widget A  | 50       | 10.00 |
      | Widget B  | 30       | 15.00 |
    When I proceed to checkout
    Then I should see a bulk discount of 10%
    And my order total should be $675.00

Developers implement step definitions that translate these steps into executable code, driving the creation of domain objects and business logic necessary to fulfill the scenarios.

Spec-First Approach uses RSpec or similar frameworks to write executable specifications at the unit and integration level without natural language scenarios. Teams write specifications that describe object behavior, then implement code to satisfy those specifications.

This approach suits teams where developers maintain close communication with stakeholders through other means, or when the application domain involves complex technical concepts difficult to express in natural language:

RSpec.describe PricingEngine do
  describe '#calculate_discount' do
    context 'for bulk orders' do
      it 'applies tiered discounts based on quantity' do
        engine = PricingEngine.new
        order = build_order(quantity: 50, unit_price: 10.00)
        
        discount = engine.calculate_discount(order)
        
        expect(discount.percentage).to eq(10)
        expect(discount.amount).to eq(50.00)
      end
    end
    
    context 'for small orders' do
      it 'applies no discount below minimum quantity' do
        engine = PricingEngine.new
        order = build_order(quantity: 5, unit_price: 10.00)
        
        discount = engine.calculate_discount(order)
        
        expect(discount.percentage).to eq(0)
        expect(discount.amount).to eq(0.00)
      end
    end
  end
end

Hybrid Approach combines natural language scenarios for high-level acceptance criteria with spec-based tests for detailed component behavior. Teams write Cucumber scenarios describing user workflows and RSpec specifications testing individual objects.

Acceptance scenarios cover the happy path and critical edge cases from the user's perspective:

Scenario: Completing a purchase
  Given I am logged in as a customer
  And I have items in my shopping cart
  When I provide valid payment information
  And I confirm my order
  Then I should receive an order confirmation
  And I should receive a confirmation email

Unit specifications verify the components supporting these workflows:

RSpec.describe PaymentProcessor do
  describe '#process' do
    it 'charges the payment method' do
      processor = PaymentProcessor.new
      payment = build_payment(amount: 100.00, method: :credit_card)
      
      result = processor.process(payment)
      
      expect(result).to be_successful
      expect(result.transaction_id).to be_present
    end
  end
end

This division keeps high-level tests focused on behavior while detailed tests verify implementation thoroughly.

Incremental Adoption introduces BDD gradually rather than converting entire projects simultaneously. Teams start by writing BDD-style tests for new features while maintaining existing test suites. Over time, as developers become comfortable with BDD practices, coverage expands.

Initial adoption often focuses on critical user workflows or areas with frequent defects. Teams gain experience with BDD tools and practices on manageable scope before expanding:

# New feature using BDD
RSpec.feature 'User Registration' do
  scenario 'Successful registration with valid information' do
    visit new_user_registration_path
    fill_in 'Email', with: 'newuser@example.com'
    fill_in 'Password', with: 'secure_password'
    click_button 'Create Account'
    
    expect(page).to have_content('Welcome!')
    expect(User.find_by(email: 'newuser@example.com')).to be_present
  end
end

# Existing feature with traditional tests (maintained as-is initially)
class UserTest < Minitest::Test
  def test_user_creation
    user = User.new(email: 'test@example.com', password: 'password')
    assert user.save
    assert_equal 'test@example.com', user.email
  end
end

Tools & Ecosystem

The Ruby BDD ecosystem includes frameworks for different testing levels, tools for test data management, and utilities for improving test maintainability.

RSpec forms the foundation of Ruby BDD testing. The framework includes multiple components for different testing needs:

  • rspec-core provides the test runner and specification structure
  • rspec-expectations supplies the matcher library for assertions
  • rspec-mocks offers test double capabilities
  • rspec-rails integrates RSpec with Ruby on Rails applications

Configuration options control test execution behavior:

# spec/spec_helper.rb
RSpec.configure do |config|
  config.expect_with :rspec do |expectations|
    expectations.include_chain_clauses_in_custom_matcher_descriptions = true
    expectations.syntax = :expect
  end
  
  config.mock_with :rspec do |mocks|
    mocks.verify_partial_doubles = true
  end
  
  config.filter_run_when_matching :focus
  config.example_status_persistence_file_path = 'spec/examples.txt'
  config.disable_monkey_patching!
  config.order = :random
  Kernel.srand config.seed
end

Cucumber excels at acceptance testing with natural language scenarios. The framework parses Gherkin feature files and executes corresponding step definitions:

# Support for data tables in scenarios
Given('the following products exist:') do |table|
  table.hashes.each do |row|
    Product.create!(
      name: row['Name'],
      price: row['Price'].to_f,
      category: row['Category']
    )
  end
end

# Background steps run before each scenario in a feature
Background:
  Given the following products exist:
    | Name          | Price | Category    |
    | Ruby Book     | 29.99 | Books       |
    | Python Book   | 34.99 | Books       |
    | Laptop        | 999.99| Electronics |

Capybara handles browser automation and page interaction. The framework supports multiple drivers:

# spec/rails_helper.rb
require 'capybara/rails'
require 'capybara/rspec'

Capybara.configure do |config|
  config.default_driver = :rack_test
  config.javascript_driver = :selenium_chrome_headless
  config.default_max_wait_time = 5
end

# Using different drivers for different scenarios
RSpec.feature 'Interactive Features', js: true do
  scenario 'Ajax-based search' do
    visit search_path
    fill_in 'Query', with: 'ruby'
    # Wait for async results to load
    expect(page).to have_css('.search-results', wait: 10)
  end
end

FactoryBot creates test data with realistic attributes and relationships:

FactoryBot.define do
  factory :order do
    association :customer
    status { :pending }
    
    trait :completed do
      status { :completed }
      completed_at { Time.current }
    end
    
    factory :order_with_items do
      transient do
        items_count { 3 }
      end
      
      after(:create) do |order, evaluator|
        create_list(:line_item, evaluator.items_count, order: order)
      end
    end
  end
end

# Creating complex test data
order = create(:order_with_items, items_count: 5, customer: premium_customer)

DatabaseCleaner manages test database state between examples:

RSpec.configure do |config|
  config.before(:suite) do
    DatabaseCleaner.strategy = :transaction
    DatabaseCleaner.clean_with(:truncation)
  end
  
  config.around(:each) do |example|
    DatabaseCleaner.cleaning do
      example.run
    end
  end
end

VCR records HTTP interactions for deterministic external API testing:

VCR.configure do |config|
  config.cassette_library_dir = 'spec/vcr_cassettes'
  config.hook_into :webmock
  config.configure_rspec_metadata!
end

RSpec.describe WeatherService do
  it 'fetches current temperature', :vcr do
    service = WeatherService.new
    temperature = service.current_temperature(zip: '90210')
    expect(temperature).to be_between(50, 100)
  end
end

SimpleCov measures test coverage to identify untested code paths:

# spec/spec_helper.rb
require 'simplecov'
SimpleCov.start 'rails' do
  add_filter '/spec/'
  add_filter '/config/'
  add_group 'Services', 'app/services'
  add_group 'Presenters', 'app/presenters'
end

Practical Examples

Real-world BDD implementations demonstrate how the methodology addresses complex scenarios across different application types.

E-commerce Order Processing shows BDD handling multi-step workflows with business rules:

Feature: Order Fulfillment
  As a warehouse manager
  I want orders to be processed automatically
  So that customers receive products quickly

  Background:
    Given the following products are in stock:
      | SKU    | Name         | Quantity |
      | BOOK01 | Ruby Guide   | 100      |
      | ELEC01 | USB Cable    | 50       |

  Scenario: Standard order fulfillment
    Given a customer order contains:
      | SKU    | Quantity |
      | BOOK01 | 2        |
      | ELEC01 | 1        |
    When the order is submitted
    Then the order status should be "processing"
    And inventory should be reserved:
      | SKU    | Reserved |
      | BOOK01 | 2        |
      | ELEC01 | 1        |
    And a pick list should be generated
    And the customer should receive a confirmation email

  Scenario: Insufficient inventory
    Given a customer order contains:
      | SKU    | Quantity |
      | BOOK01 | 150      |
    When the order is submitted
    Then the order status should be "pending_inventory"
    And the customer should be notified about the delay
    And a restock notification should be created

Step definitions implement these scenarios:

Given('the following products are in stock:') do |table|
  table.hashes.each do |row|
    Product.create!(
      sku: row['SKU'],
      name: row['Name'],
      stock_quantity: row['Quantity'].to_i
    )
  end
end

Given('a customer order contains:') do |table|
  @order = Order.create!(customer: @current_customer || create(:customer))
  table.hashes.each do |row|
    product = Product.find_by(sku: row['SKU'])
    @order.line_items.create!(product: product, quantity: row['Quantity'].to_i)
  end
end

When('the order is submitted') do
  @order.submit!
end

Then('inventory should be reserved:') do |table|
  table.hashes.each do |row|
    product = Product.find_by(sku: row['SKU'])
    reservation = InventoryReservation.find_by(product: product, order: @order)
    expect(reservation.quantity).to eq(row['Reserved'].to_i)
  end
end

API Authentication System demonstrates BDD for security-critical features:

RSpec.describe 'API Authentication' do
  describe 'POST /api/v1/tokens' do
    context 'with valid credentials' do
      it 'returns an access token and refresh token' do
        user = create(:user, email: 'api@example.com', password: 'secure_pass')
        
        post '/api/v1/tokens', params: {
          email: 'api@example.com',
          password: 'secure_pass'
        }
        
        expect(response).to have_http_status(:created)
        json = JSON.parse(response.body)
        expect(json['access_token']).to be_present
        expect(json['refresh_token']).to be_present
        expect(json['expires_in']).to eq(3600)
      end
      
      it 'records the login timestamp' do
        user = create(:user, email: 'api@example.com', password: 'secure_pass')
        
        expect {
          post '/api/v1/tokens', params: {
            email: 'api@example.com',
            password: 'secure_pass'
          }
        }.to change { user.reload.last_login_at }
      end
    end
    
    context 'with invalid credentials' do
      it 'returns unauthorized status' do
        create(:user, email: 'api@example.com', password: 'secure_pass')
        
        post '/api/v1/tokens', params: {
          email: 'api@example.com',
          password: 'wrong_password'
        }
        
        expect(response).to have_http_status(:unauthorized)
        expect(JSON.parse(response.body)['error']).to eq('Invalid credentials')
      end
      
      it 'increments failed login counter' do
        user = create(:user, email: 'api@example.com', password: 'secure_pass')
        
        expect {
          post '/api/v1/tokens', params: {
            email: 'api@example.com',
            password: 'wrong_password'
          }
        }.to change { user.reload.failed_login_attempts }.by(1)
      end
    end
    
    context 'with locked account' do
      it 'rejects authentication attempts' do
        user = create(:user, :locked, email: 'api@example.com')
        
        post '/api/v1/tokens', params: {
          email: 'api@example.com',
          password: 'secure_pass'
        }
        
        expect(response).to have_http_status(:forbidden)
        expect(JSON.parse(response.body)['error']).to eq('Account locked')
      end
    end
  end
end

Background Job Processing illustrates BDD for asynchronous operations:

RSpec.describe 'Report Generation' do
  describe 'generating sales reports' do
    context 'with valid date range' do
      it 'creates a report job' do
        expect {
          post '/reports', params: {
            type: 'sales',
            start_date: '2025-01-01',
            end_date: '2025-01-31'
          }
        }.to have_enqueued_job(GenerateReportJob)
      end
      
      it 'notifies user when report is ready', :perform_enqueued_jobs do
        user = create(:user, email: 'manager@example.com')
        sign_in(user)
        
        post '/reports', params: {
          type: 'sales',
          start_date: '2025-01-01',
          end_date: '2025-01-31'
        }
        
        expect(ActionMailer::Base.deliveries.last.to).to include('manager@example.com')
        expect(ActionMailer::Base.deliveries.last.subject).to include('Report Ready')
      end
    end
    
    context 'when report generation fails' do
      it 'retries the job', :perform_enqueued_jobs do
        allow_any_instance_of(ReportGenerator).to receive(:generate).and_raise(StandardError)
        
        post '/reports', params: {
          type: 'sales',
          start_date: '2025-01-01',
          end_date: '2025-01-31'
        }
        
        expect(GenerateReportJob).to have_been_enqueued.at_least(3).times
      end
    end
  end
end

Common Pitfalls

BDD implementations encounter recurring problems that undermine the methodology's benefits. Recognizing these pitfalls helps teams maintain effective BDD practices.

Overly Technical Scenarios defeat the purpose of natural language specifications when they contain implementation details. Scenarios should describe user behavior, not technical mechanisms:

# Poor: Implementation details leak into scenario
Scenario: User registration
  Given I POST to "/api/users" with JSON payload
  And the database table "users" is empty
  When the UserCreationService processes the request
  Then a record should exist in "users" table
  And the password_digest column should be populated

# Better: Focus on user-visible behavior
Scenario: User registration
  Given I am on the registration page
  When I sign up with email "user@example.com"
  And I provide a valid password
  Then I should be logged in
  And I should see a welcome message

Technical terminology makes scenarios unreadable to business stakeholders and ties specifications to implementation details that may change.

Testing Implementation Rather Than Behavior occurs when specifications verify internal object state instead of observable outcomes:

# Poor: Testing implementation details
RSpec.describe Order do
  it 'sets the internal state machine to submitted' do
    order = Order.new
    order.submit
    expect(order.instance_variable_get(:@state)).to eq(:submitted)
  end
end

# Better: Testing behavior
RSpec.describe Order do
  it 'prevents modifications after submission' do
    order = Order.new
    order.submit
    expect { order.add_item(product) }.to raise_error(Order::AlreadySubmitted)
  end
end

Behavior-focused tests remain valid when refactoring implementation, while implementation-focused tests break during routine code changes.

Duplicate Step Definitions create maintenance burden when multiple step definitions do the same thing with slightly different wording:

# Poor: Multiple steps for the same action
Given('I am logged in') do
  login_as(create(:user))
end

Given('I have signed in') do
  login_as(create(:user))
end

Given('I am authenticated') do
  login_as(create(:user))
end

# Better: Single step, consistent wording
Given('I am logged in as {string}') do |role|
  user = create(:user, role: role)
  login_as(user)
end

Teams should establish conventions for common actions and reuse step definitions across scenarios.

Insufficient Isolation Between Tests causes failures when tests depend on execution order or shared state:

# Poor: Tests share state
RSpec.describe ShoppingCart do
  before(:all) do
    @cart = ShoppingCart.new
  end
  
  it 'adds items' do
    @cart.add_item(create(:product))
    expect(@cart.item_count).to eq(1)
  end
  
  it 'calculates total' do
    # Fails if previous test didn't run first
    expect(@cart.total).to be > 0
  end
end

# Better: Each test is independent
RSpec.describe ShoppingCart do
  it 'adds items' do
    cart = ShoppingCart.new
    cart.add_item(create(:product, price: 10.00))
    expect(cart.item_count).to eq(1)
  end
  
  it 'calculates total' do
    cart = ShoppingCart.new
    cart.add_item(create(:product, price: 10.00))
    expect(cart.total).to eq(10.00)
  end
end

Imperative Rather Than Declarative Scenarios describe every UI interaction instead of focusing on intent:

# Poor: Imperative steps describing mechanics
Scenario: Adding a product to cart
  Given I am on the homepage
  When I click the "Products" link
  And I scroll down to "Ruby Book"
  And I click "View Details"
  And I click "Add to Cart"
  And I click the cart icon
  Then I should see "Ruby Book" in the cart

# Better: Declarative steps describing intent
Scenario: Adding a product to cart
  Given I am browsing products
  When I add "Ruby Book" to my cart
  Then my cart should contain "Ruby Book"

Declarative scenarios remain valid when UI implementation changes, while imperative scenarios require updates for every interface modification.

Vague or Ambiguous Step Definitions create confusion and make scenarios difficult to implement:

# Poor: Vague implementation
When('I submit the form') do
  click_button 'Submit'
end

# Better: Clear about which form
When('I submit the registration form') do
  within('#registration-form') do
    click_button 'Create Account'
  end
end

When('I submit the search form') do
  within('#search-form') do
    click_button 'Search'
  end
end

Testing Through the UI Exclusively makes tests slow and brittle. Not all scenarios require full browser automation:

# Slow: Every test goes through UI
RSpec.feature 'Order Processing' do
  scenario 'calculating discount' do
    visit new_order_path
    fill_in 'Customer', with: 'Premium Customer'
    fill_in 'Amount', with: '1000'
    click_button 'Calculate'
    expect(page).to have_content('Discount: $100')
  end
end

# Fast: Test business logic directly
RSpec.describe DiscountCalculator do
  it 'applies premium customer discount' do
    calculator = DiscountCalculator.new
    order = build(:order, customer_type: :premium, amount: 1000)
    discount = calculator.calculate(order)
    expect(discount).to eq(100)
  end
end

# UI test covers integration only
RSpec.feature 'Order Processing' do
  scenario 'displaying calculated discount' do
    visit new_order_path
    fill_in 'Amount', with: '1000'
    select 'Premium', from: 'Customer Type'
    click_button 'Calculate'
    expect(page).to have_content('Discount: $100')
  end
end

Reference

BDD Frameworks Comparison

Framework Purpose Syntax Style Typical Use Case
RSpec Unit and integration testing Specification DSL Component behavior verification
Cucumber Acceptance testing Natural language (Gherkin) Business-readable scenarios
Capybara Browser automation Ruby DSL User interaction simulation
Minitest::Spec Unit testing Specification DSL Lightweight behavioral testing

Gherkin Keywords

Keyword Purpose Example
Feature Describes functionality being tested Feature: User Authentication
Background Steps run before each scenario Background: Given a registered user
Scenario Concrete example of behavior Scenario: Login with valid credentials
Given Establishes context Given I am on the login page
When Describes action When I enter my credentials
Then States expected outcome Then I should see my dashboard
And Continues previous step type And I should see a welcome message
But Negative continuation But I should not see login form

RSpec Structure Components

Component Purpose Example
describe Groups related specifications describe Order do
context Describes specific condition context 'when empty' do
it States expected behavior it 'returns zero' do
before Setup run before examples before { create(:user) }
after Cleanup run after examples after { clear_cache }
let Lazy-evaluated memoized helper let(:user) { create(:user) }
let! Eager-evaluated helper let!(:product) { create(:product) }
subject Declares example subject subject { Order.new }

Common RSpec Matchers

Matcher Purpose Example
eq Exact equality expect(result).to eq(42)
be Identity comparison expect(obj).to be(same_obj)
be_nil Nil check expect(value).to be_nil
be_truthy Truthiness check expect(condition).to be_truthy
include Collection membership expect(array).to include(item)
match Regex matching expect(text).to match(/pattern/)
raise_error Exception expectation expect { code }.to raise_error(Error)
change State change verification expect { action }.to change { count }.by(1)
have_attributes Attribute verification expect(obj).to have_attributes(name: 'Test')
be_within Approximate equality expect(value).to be_within(0.1).of(3.14)

Capybara Actions

Action Purpose Example
visit Navigate to path visit products_path
click_link Click link by text click_link 'View Details'
click_button Click button by text click_button 'Submit'
fill_in Enter text in field fill_in 'Email', with: 'user@example.com'
select Choose from dropdown select 'Premium', from: 'Membership'
check Check checkbox check 'Terms of Service'
choose Select radio button choose 'Credit Card'
attach_file Upload file attach_file 'Avatar', file_path

Capybara Matchers

Matcher Purpose Example
have_content Text presence expect(page).to have_content('Welcome')
have_css CSS selector expect(page).to have_css('.error')
have_selector Generic selector expect(page).to have_selector('h1', text: 'Title')
have_link Link presence expect(page).to have_link('Sign Out')
have_button Button presence expect(page).to have_button('Submit')
have_field Form field presence expect(page).to have_field('Email')
have_current_path URL verification expect(page).to have_current_path(dashboard_path)

FactoryBot Patterns

Pattern Purpose Example
create Build and save record create(:user)
build Build without saving build(:user)
build_stubbed Build with stubbed attributes build_stubbed(:user)
create_list Create multiple records create_list(:product, 5)
trait Define attribute variation factory :user do trait :admin
transient Define build-time parameters transient do items_count { 3 } end
association Define relationship association :author, factory: :user
sequence Generate unique values sequence(:email) { "user#{n}@example.com" }

BDD Best Practices Checklist

Practice Description
Write scenarios before code Define expected behavior before implementation
Use ubiquitous language Match domain terminology in all artifacts
Keep scenarios declarative Describe what, not how
One behavior per scenario Each scenario tests single outcome
Maintain test independence Each test runs successfully in isolation
Test behavior, not implementation Verify observable outcomes
Use appropriate test level Unit tests for logic, acceptance for workflows
Keep step definitions reusable Share common steps across scenarios
Run tests frequently Catch regressions early
Refactor tests with code Keep specifications maintainable