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
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
BDD workflow in Rails
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)
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
What “good BDD” looks like in Rails
Common mistakes to avoid
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
Example user story
As a member, I want to bookmark an article so I can read it later from my saved list.
Acceptance scenarios
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
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
When to prefer BDD
When to prefer TDD
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-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
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
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.
Anti-pattern 2: Too many full UI specs
Heavy Capybara coverage for every branch makes CI slow and brittle.
Anti-pattern 3: Ambiguous scenario language
“User can manage account” is too broad and leads to weak tests.
Anti-pattern 4: Over-mocking external behavior
Excessive stubs can hide integration failures.
Anti-pattern 5: No edge-case scenarios
Only happy paths are covered, so production fails on invalid input or permission checks.
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
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
Reliability strategy
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
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
Roles and responsibilities
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
Common collaboration failures
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
Posted on February 05, 2026 by Amit Pandya in BDD