Desk2Mob

Desk2Mob

Desk2Mob

BDD in Ruby on Rails: A Practical, Step-by-Step Guide

BDD in Rails

I created this mini series to highlight the importance of BDD in modern software development and why it matters for real teams. This guide explains how BDD improves communication between product, QA, and engineering by using shared behavior-focused language. It also shows how BDD helps teams reduce regressions, design cleaner code, and ship features with more confidence. Across these seven parts, you will get practical examples, common mistakes to avoid, and proven workflows you can apply in Rails projects. My goal is to give you a complete, actionable path from BDD basics to production-ready implementation.

Series Index

  • Part 1: BDD in Ruby on Rails: A Practical, Step-by-Step Guide
  • Part 2: BDD in Rails in 2026: From User Story to Production-Ready Spec
  • Part 3: BDD vs TDD in Ruby on Rails: When to Use Each
  • Part 4: Full BDD Flow in Rails: Feature Spec + Request Spec + Model Spec
  • Part 5: BDD Anti-Patterns in Rails and How to Fix Them
  • Part 6: BDD in Rails: Improving Test Speed and Reliability
  • Part 7: BDD Team Workflow for Rails: Product, Dev, and QA Collaboration

Part 1

BDD in Ruby on Rails: A Practical, Step-by-Step Guide

What is BDD?

BDD (Behaviour Driven Development) is a development approach where you describe application behavior in business language first, then automate those behaviors as tests, and finally implement code to make those tests pass. In short: you build software from expected user behavior, not from technical assumptions.

BDD is often expressed with scenarios like “Given, When, Then,” so product owners, developers, and QA can all understand the same requirement.

BDD vs "BBD"

The correct term is BDD (Behaviour Driven Development). People sometimes type BBD by mistake.

Why BDD matters in Rails projects

  • Shared understanding: Business and engineering discuss behavior in the same language.
  • Fewer regressions: Behavior-focused tests protect critical user flows.
  • Cleaner design: Writing behavior first encourages smaller, testable classes and controllers.
  • Living documentation: Feature specs describe what the app should do and why.
  • Safer refactoring: You can improve internals without breaking expected outcomes.

BDD workflow in Rails

  • Step 1 - Discovery: Discuss a user problem and expected outcome.
  • Step 2 - Define scenarios: Write acceptance examples in Given/When/Then style.
  • Step 3 - Automate failing test: Add a feature spec first and see it fail.
  • Step 4 - Implement minimal code: Add only enough code to pass the behavior.
  • Step 5 - Refactor: Improve structure while keeping tests green.
  • Step 6 - Repeat: Add more behaviors and edge cases iteratively.

Example: BDD for User Sign In in Rails

Business requirement

A registered user should be able to sign in with valid credentials and should see an error message for invalid credentials.

Scenarios (Given/When/Then)

  • Scenario A: Given a registered user, when valid email/password are submitted, then user is redirected to dashboard with success message.
  • Scenario B: Given a registered user, when invalid password is submitted, then sign-in page is shown with error message.

Step 1 - Add test tooling

# Gemfile (test/development)
group :development, :test do
  gem 'rspec-rails'
  gem 'capybara'
  gem 'factory_bot_rails'
end

Step 2 - Write failing feature spec first

# spec/features/user_sign_in_spec.rb
require 'rails_helper'

RSpec.feature "User sign in", type: :feature do
  scenario "valid credentials" do
    user = FactoryBot.create(:user, email: "john@example.com", password: "secret123")

    visit "/signin"
    fill_in "Email", with: "john@example.com"
    fill_in "Password", with: "secret123"
    click_button "Sign in"

    expect(page).to have_content("Signed in successfully")
    expect(page).to have_current_path("/dashboard")
  end

  scenario "invalid credentials" do
    FactoryBot.create(:user, email: "john@example.com", password: "secret123")

    visit "/signin"
    fill_in "Email", with: "john@example.com"
    fill_in "Password", with: "wrongpass"
    click_button "Sign in"

    expect(page).to have_content("Invalid email/password")
    expect(page).to have_current_path("/signin")
  end
end

Step 3 - Implement minimum Rails code

# config/routes.rb
get "/signin", to: "sessions#new"
post "/signin", to: "sessions#create"
get "/dashboard", to: "dashboard#show"

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def new
  end

  def create
    user = User.find_by(email: params[:email].to_s.downcase)

    if user&.authenticate(params[:password])
      session[:user_id] = user.id
      redirect_to "/dashboard", notice: "Signed in successfully"
    else
      flash.now[:alert] = "Invalid email/password"
      render :new, status: :unprocessable_entity
    end
  end
end

Step 4 - Add minimal view

# app/views/sessions/new.html.erb
<p>Sign in</p>
<%= form_with url: "/signin", method: :post do |f| %>
  <%= label_tag :email, "Email" %>
  <%= email_field_tag :email %>

  <%= label_tag :password, "Password" %>
  <%= password_field_tag :password %>

  <%= submit_tag "Sign in" %>
<% end %>

Step 5 - Run tests, pass, then refactor

  • Refactor session logic: Move authentication flow to a service object if it grows.
  • Add edge cases: blank email, locked account, inactive user.
  • Harden behavior: add authorization checks and redirect-back behavior.

What “good BDD” looks like in Rails

  • Behavior-first naming: spec descriptions read like user outcomes, not implementation details.
  • Right test level: feature specs for workflows, request/model specs for rules.
  • Small iterations: one behavior at a time, always red to green.
  • Business-readable tests: non-technical stakeholders can follow expected outcomes.

Common mistakes to avoid

  • Testing internals only: BDD should validate user-visible behavior.
  • Huge scenarios: keep each scenario focused on one business rule.
  • Skipping discovery: unclear requirements create brittle tests.
  • Over-mocking: too much mocking can hide real integration issues.

BDD in Ruby on Rails helps teams translate business requirements into executable behavior. When you consistently follow discovery, scenario writing, failing test first, minimal implementation, and refactoring, you get better communication, stronger quality, and safer delivery.

Part 2

From Part 1 fundamentals, this part moves into a modern production-ready BDD workflow.

BDD in Rails in 2026: From User Story to Production-Ready Spec

Why this article matters

BDD is still relevant, but modern Rails teams now care equally about speed, clarity, and maintainability. The best BDD workflow in 2026 starts with business behavior, then maps that behavior to the right level of automated specs, and ends with clean code and fast feedback.

A production-ready BDD sequence

  • Define user outcome: Clarify business value in one sentence before writing code.
  • Write acceptance scenario: Use Given/When/Then style that product and QA can read.
  • Create failing feature spec: Prove the behavior is currently missing.
  • Add request/model specs: Cover rules and edge cases behind the feature.
  • Implement minimum code: Pass tests with the smallest safe change.
  • Refactor safely: Improve design while preserving behavior with green specs.

Example user story

As a member, I want to bookmark an article so I can read it later from my saved list.

Acceptance scenarios

  • Scenario 1: Given a signed-in user, when they click bookmark, then the article appears in Saved Articles.
  • Scenario 2: Given a guest user, when they click bookmark, then they are redirected to sign-in.
  • Scenario 3: Given an already bookmarked article, when user bookmarks again, then duplicate bookmark is not created.

Feature spec sketch

# spec/features/bookmark_article_spec.rb
require 'rails_helper'

RSpec.feature "Bookmark article", type: :feature do
  scenario "member bookmarks successfully" do
    user = create(:user)
    article = create(:article)
    sign_in_as(user)

    visit article_path(article)
    click_button "Save"

    expect(page).to have_content("Saved")
    visit saved_articles_path
    expect(page).to have_content(article.title)
  end
end

Request spec sketch

# spec/requests/bookmarks_spec.rb
require 'rails_helper'

RSpec.describe "Bookmarks", type: :request do
  it "creates bookmark for authenticated user" do
    user = create(:user)
    article = create(:article)
    sign_in user

    post article_bookmarks_path(article)

    expect(response).to redirect_to(article_path(article))
    expect(user.bookmarks.where(article: article).count).to eq(1)
  end
end

Model rule sketch

# app/models/bookmark.rb
class Bookmark < ApplicationRecord
  belongs_to :user
  belongs_to :article

  validates :article_id, uniqueness: { scope: :user_id }
end

What makes it production-ready

  • Behavior is traceable: Every rule links back to a user-visible scenario.
  • Specs are layered: Slow UI flow is minimal; core rules are fast at lower levels.
  • Edge cases are explicit: Auth, duplicates, invalid input, and permissions are covered.
  • Refactoring is safe: You can change internals without changing outcomes.

In 2026 Rails teams, BDD works best when behavior definitions are clear and specs are placed at the right level. This gives better collaboration, faster CI, and more reliable releases.

Part 3

From workflow design, this part clarifies when to use BDD and when TDD is the better fit.

BDD vs TDD in Ruby on Rails: When to Use Each

Core difference

  • TDD asks: Does this method/class work correctly?
  • BDD asks: Does the system behave correctly for the user?

When to prefer BDD

  • User workflows are critical: Signup, checkout, approvals, subscriptions.
  • Cross-team collaboration needed: Product, QA, and engineering align on scenarios.
  • Risk of misunderstood requirements: Behavior statements reduce ambiguity.

When to prefer TDD

  • Complex domain logic: Calculations, scoring engines, data transformations.
  • Fast technical feedback needed: Unit specs run quickly and pinpoint failures.
  • Low UI relevance: Internal service objects not directly visible to users.

Recommended Rails strategy

Use BDD to define outcomes, then use TDD to implement internal logic. This gives strong product alignment and clean technical design.

Example split for one feature

  • BDD: “Given expired card, payment fails with actionable message.”
  • TDD: Unit specs for payment gateway response mapping and retry rules.

BDD-style feature example

scenario "expired card shows payment failure" do
  sign_in_as(user)
  visit checkout_path
  fill_in "Card Number", with: "4000000000000069"
  click_button "Pay"

  expect(page).to have_content("Your card has expired")
end

TDD-style service example

RSpec.describe PaymentResultMapper do
  it "maps gateway expired_card code to user message" do
    mapper = PaymentResultMapper.new(code: "expired_card")
    expect(mapper.user_message).to eq("Your card has expired")
  end
end

Common mistake

Teams sometimes write only feature specs and call it BDD. That creates slow, brittle suites. Balanced BDD+TDD gives better speed and reliability.

Use BDD to decide what behavior matters. Use TDD to build the internals correctly. In Rails, the combination is usually better than choosing only one.

Part 4

From choosing BDD vs TDD, this part shows a complete layered implementation flow.

Full BDD Flow in Rails: Feature Spec + Request Spec + Model Spec

Why this structure works

A full BDD flow should verify behavior at three layers: user journey, HTTP boundary, and domain rule. This catches real issues while keeping most tests fast.

Use case

As a user, I want to submit feedback so support can contact me later.

Scenario

  • Given: User opens feedback form.
  • When: User submits valid email and message.
  • Then: Feedback is saved and success message is shown.

Step 1 - Feature spec (user behavior)

# spec/features/submit_feedback_spec.rb
require 'rails_helper'

RSpec.feature "Submit feedback", type: :feature do
  scenario "valid submission" do
    visit new_feedback_path
    fill_in "Email", with: "alex@example.com"
    fill_in "Message", with: "Great product, please add dark mode"
    click_button "Send"

    expect(page).to have_content("Thank you for your feedback")
  end
end

Step 2 - Request spec (controller contract)

# spec/requests/feedbacks_spec.rb
require 'rails_helper'

RSpec.describe "Feedbacks", type: :request do
  it "creates feedback and redirects" do
    post feedbacks_path, params: {
      feedback: { email: "alex@example.com", message: "Great app" }
    }

    expect(response).to redirect_to(new_feedback_path)
    expect(Feedback.count).to eq(1)
  end
end

Step 3 - Model spec (business rules)

# spec/models/feedback_spec.rb
require 'rails_helper'

RSpec.describe Feedback, type: :model do
  it "is invalid without email" do
    feedback = Feedback.new(message: "Hello")
    expect(feedback).not_to be_valid
  end

  it "is invalid without message" do
    feedback = Feedback.new(email: "alex@example.com")
    expect(feedback).not_to be_valid
  end
end

Step 4 - Minimal implementation

# app/models/feedback.rb
class Feedback < ApplicationRecord
  validates :email, presence: true
  validates :message, presence: true, length: { minimum: 5 }
end

# app/controllers/feedbacks_controller.rb
class FeedbacksController < ApplicationController
  def new
    @feedback = Feedback.new
  end

  def create
    @feedback = Feedback.new(feedback_params)
    if @feedback.save
      redirect_to new_feedback_path, notice: "Thank you for your feedback"
    else
      render :new, status: :unprocessable_entity
    end
  end

  private

  def feedback_params
    params.require(:feedback).permit(:email, :message)
  end
end

Step 5 - Refine and expand

  • Add invalid email case: model format validation and request spec for error response.
  • Add spam protection: rate limiting or honeypot behavior tested by request specs.
  • Add notifications: unit test service object that sends support email.

This three-layer structure keeps BDD realistic and maintainable: one small feature spec for behavior confidence, with most detail covered in faster request/model specs.

Part 5

From the full flow, this part focuses on common BDD mistakes and practical fixes.

BDD Anti-Patterns in Rails and How to Fix Them

Why anti-patterns matter

Many teams adopt BDD but still struggle with flaky tests and unclear requirements. The issue is rarely BDD itself; it is usually how scenarios and specs are written.

Anti-pattern 1: Testing implementation details

Example: asserting private methods were called instead of asserting user-visible outcomes.

  • Fix: Assert behavior at the boundary, such as response, page state, created records, and business events.

Anti-pattern 2: Too many full UI specs

Heavy Capybara coverage for every branch makes CI slow and brittle.

  • Fix: Keep one or two happy-path feature specs; move rule branches to request/model specs.

Anti-pattern 3: Ambiguous scenario language

“User can manage account” is too broad and leads to weak tests.

  • Fix: Write explicit Given/When/Then outcomes with data and expected result.

Anti-pattern 4: Over-mocking external behavior

Excessive stubs can hide integration failures.

  • Fix: Mock at system boundaries only; keep internal collaboration real where possible.

Anti-pattern 5: No edge-case scenarios

Only happy paths are covered, so production fails on invalid input or permission checks.

  • Fix: Always add at least one negative and one authorization scenario.

Weak vs strong scenario example

# Weak
Scenario: User updates profile

# Strong
Scenario: User updates profile with invalid email
Given I am signed in
When I submit profile form with email "bad-email"
Then I see "Email is invalid"
And my previous email remains unchanged

Refactoring checklist for BDD suites

  • Remove duplicate flows: Keep one canonical UI path per behavior.
  • Extract helpers carefully: Keep specs readable and behavior-focused.
  • Stabilize data setup: Use factories with explicit traits.
  • Make failures actionable: Assert clear, meaningful outcomes.

Good BDD is not “more tests.” It is clear behavior, correct test placement, and stable feedback. Remove anti-patterns and your Rails suite becomes faster and more trustworthy.

Part 6

From anti-patterns, this part explains how to keep BDD suites fast and reliable in CI.

BDD in Rails: Improving Test Speed and Reliability

The practical problem

BDD suites often start clean and become slow over time. The fix is not to reduce quality; the fix is to place tests at the right layer and make setup deterministic.

Performance strategy

  • Use test pyramid balance: Few feature specs, more request specs, many model/service specs.
  • Keep feature specs focused: Cover only critical end-to-end behaviors.
  • Prefer factories with traits: Avoid expensive object graphs when not needed.
  • Run specs in parallel: Use parallel test execution in CI.

Reliability strategy

  • Control time: Freeze time in tests for deterministic assertions.
  • Control randomness: Seed random order and reproduce flaky failures.
  • Avoid async race conditions: Use inline job adapters for unit/request specs.
  • Use stable selectors: Prefer semantic labels/test ids over fragile CSS paths.

Example: deterministic time

# spec/services/subscription_renewal_spec.rb
require 'rails_helper'

RSpec.describe SubscriptionRenewal do
  it "renews on due date" do
    travel_to(Time.zone.parse("2026-02-01 10:00:00")) do
      subscription = create(:subscription, renew_on: Date.current)
      SubscriptionRenewal.call(subscription)
      expect(subscription.reload.status).to eq("active")
    end
  end
end

Example: request spec over feature spec for rule branches

# spec/requests/coupons_spec.rb
RSpec.describe "Coupons", type: :request do
  it "rejects expired coupon" do
    coupon = create(:coupon, expires_at: 1.day.ago)

    post apply_coupon_path, params: { code: coupon.code }

    expect(response).to have_http_status(:unprocessable_entity)
    expect(response.body).to include("Coupon is expired")
  end
end

CI guardrails

  • Track slowest examples: review top 20 slow specs every sprint.
  • Quarantine flaky tests briefly: fix root cause within agreed SLA.
  • Fail fast on randomness: keep random spec order enabled in CI.

BDD does not have to be slow. With the right pyramid, deterministic setup, and CI discipline, Rails teams can keep behavior confidence high and feedback loops fast.

Part 7

From technical quality, this final part covers team collaboration and delivery workflow.

BDD Team Workflow for Rails: Product, Dev, and QA Collaboration

Why process matters

BDD succeeds when teams collaborate before coding, not only during code review. A lightweight workflow creates shared understanding and prevents expensive rework.

A simple weekly BDD workflow

  • Backlog refinement: Product presents user outcomes and business constraints.
  • Example mapping session: Team defines rules, examples, and open questions.
  • Scenario agreement: QA and dev agree on Given/When/Then acceptance criteria.
  • Implementation cycle: Dev writes failing specs first, then minimal code.
  • Demo with scenarios: Show completed behavior using original acceptance examples.

Roles and responsibilities

  • Product Manager: defines business value, priorities, and edge constraints.
  • Developer: translates scenarios into layered specs and implementation.
  • QA Engineer: challenges ambiguity, expands negative and boundary cases.

Example mapping template

Feature: Password reset
Business goal: Reduce support tickets for account recovery

Rules:
- Reset link expires in 30 minutes
- Link can be used once
- Unknown email should return generic success message

Examples:
- Valid token within 30 min -> password updated
- Expired token -> user sees expiration message
- Reused token -> user sees invalid token message

Definition of Done for BDD stories

  • Acceptance scenarios approved: Product and QA sign off before coding.
  • Specs at correct levels: Feature + request/model coverage is balanced.
  • Negative cases included: invalid input and authorization paths are tested.
  • CI green: no flaky failures and acceptable runtime.
  • Scenario-based demo completed: team validates behavior against agreed examples.

Common collaboration failures

  • Late requirement clarification: causes rewrites and fragile test updates.
  • No shared language: technical jargon blocks product participation.
  • Scenario drift: implemented behavior no longer matches accepted criteria.

BDD is a team practice, not only a testing style. When product, dev, and QA align on examples early, Rails delivery becomes faster, clearer, and more predictable.

Next actions checklist

  • Run one example-mapping session: pick a single upcoming story and define Given/When/Then outcomes.
  • Apply layered specs: keep one feature spec and move rule branches to request/model specs.
  • Set BDD Definition of Done: require approved scenarios before implementation starts.
  • Track suite health weekly: review flaky tests and top slow specs as part of team routine.

Posted on February 05, 2026 by Amit Pandya in BDD


All Posts