How to Implement TDD in Rails – Step-by-Step with Examples

How to Implement TDD in Rails – Step-by-Step with Examples

How to Implement TDD in Ruby on Rails: A Complete Guide

Why We Need TDD?

  • Write only the necessary code by testing first.
  • Catch bugs early during development.
  • Code becomes modular and easier to maintain.
  • Tests act as self-documentation.
  • Confidence in making changes and refactoring.

TDD Workflow in Rails

  1. Install RSpec with:
    bundle add rspec-rails –group “development, test”
    rails generate rspec:install
  2. Write a failing test.
  3. Write minimal code to pass the test.
  4. Refactor the code while keeping the test green.
  5. Repeat the cycle for new features.

From Requirement to Code: A TDD Scenario

📋 Scenario: Build a Simple Task Manager

Let’s walk through how TDD works in real-world Rails development. Imagine you are building a Task Manager application where users can:

  • Create a task with a title and deadline
  • Mark tasks as complete
  • Prevent tasks from having past deadlines

✅ Step 1: Understand Requirements

  • Title must be present and at least 5 characters
  • Deadline must be today or a future date
  • Users can toggle task status between complete/incomplete

đŸ§Ș Step 2: Write Failing Tests

describe Task do
it "is invalid without a title" do
task = Task.new(title: nil)
expect(task).not_to be_valid
end

it "is invalid if title is too short" do
task = Task.new(title: "Do")
expect(task).not_to be_valid
end

it "is invalid with past deadline" do
task = Task.new(title: "Plan", deadline: Date.yesterday)
expect(task).not_to be_valid
end
end

đŸ’» Step 3: Implement Minimal Code

Now add validations in the Task model to make those tests pass:

class Task < ApplicationRecord
validates :title, presence: true, length: { minimum: 5 }
validate :deadline_cannot_be_in_the_past

def deadline_cannot_be_in_the_past
if deadline.present? && deadline < Date.today
errors.add(:deadline, "can't be in the past")
end
end
end

🔁 Step 4: Add Feature Test for Toggling Completion

Then write another test for toggling status:

it "can toggle completion status" do
task = Task.create!(title: "Write Blog", deadline: Date.tomorrow)
expect(task.complete).to eq(false)
task.toggle!(:complete)
expect(task.complete).to eq(true)
end

🚀 Step 5: Refactor and Repeat

You now have a working, tested feature. Continue this cycle for every new requirement:

  • Write a failing test
  • Write minimum code to pass
  • Refactor while keeping the test green

This method keeps development focused, prevents regressions, and builds a reliable Rails app feature by feature.

Case Study: Using TDD to Build a Secure Login Flow in Rails

📋 Background

A SaaS startup was building a new Ruby on Rails application with user accounts, including a login and session management system. Security and correctness were top priorities, especially for handling failed login attempts, invalid sessions, and password hashing.

🎯 Why TDD?

  • Authentication logic has many edge cases (blank input, wrong password, inactive account, etc.)
  • Security-critical feature — mistakes can lead to data breaches
  • CI/CD pipeline needed tests to prevent broken auth from deploying

✅ Requirements

  • User can log in using email and password
  • Passwords must be securely stored (not plain text)
  • Invalid credentials should show a proper error message
  • Session should persist the user across requests
  • Provide logout functionality that ends the session

đŸ§Ș Step 1: Write Failing Tests First (TDD)

RSpec request and model specs were created before any controller code.

describe "POST /login" do
let!(:user) { User.create(email: "[email protected]", password: "password123") }

it "logs in with valid credentials" do
post "/login", params: { email: "[email protected]", password: "password123" }
expect(response).to have_http_status(:found)
expect(session[:user_id]).to eq(user.id)
end

it "rejects invalid credentials" do
post "/login", params: { email: "[email protected]", password: "wrong" }
expect(response).to have_http_status(:unauthorized)
end
end

đŸ’» Step 2: Write Just Enough Code to Pass

Basic login logic was implemented in a `SessionsController`:

class SessionsController < ApplicationController
def create
user = User.find_by(email: params[:email])
if user && user.authenticate(params[:password])
session[:user_id] = user.id
redirect_to dashboard_path
else
render json: { error: "Invalid credentials" }, status: :unauthorized
end
end
end

And the User model used `has_secure_password`:

class User < ApplicationRecord
has_secure_password
validates :email, presence: true, uniqueness: true
end

🔁 Step 3: Refactor & Expand Coverage

  • Added flash messages and integration tests with Capybara
  • Tested logout flow and redirection
  • Tested session expiration and invalid session access

⚙ CI/CD Integration

The team used GitHub Actions to run the test suite on every pull request:

  • All login specs were green before merging
  • Bug: One PR had accidentally removed session clearing during logout — tests caught it
  • New developer onboarded and understood auth flow by reading test files

📈 Outcome

  • 100% test coverage for login-related code
  • Zero login-related production bugs in first 3 months
  • Easy refactoring of authentication logic later to support OAuth, without regressions
  • Peace of mind for developers pushing auth updates

💡 Key Takeaways

  • Write request specs first for auth — they mimic the real user experience
  • Always test failure cases: wrong password, empty fields, expired sessions
  • TDD makes authentication not only more secure, but easier to evolve

Case Study: TDD in a Rails API for a Fintech Startup

🚀 Project Background

A fintech startup was building a Rails API to handle loan applications and credit scoring. The app required strict validation, secure processing, and minimal downtime.

🔍 Why TDD Was Chosen

  • Financial data needs high confidence in logic
  • Many edge cases (e.g. missing income, invalid amounts)
  • Multiple devs working on features in parallel
  • Need for CI/CD pipelines with green test gates

đŸ§Ș TDD Workflow Example: Loan Approval Logic

Requirement: A loan should only be approved if:

  • The applicant is over 21
  • Monthly income is more than 3x the requested EMI
describe LoanApplication do
it "rejects if applicant is underage" do
loan = LoanApplication.new(age: 19)
expect(loan).not_to be_approved
end

it "approves if income is sufficient" do
loan = LoanApplication.new(age: 25, income: 60000, emi: 15000)
expect(loan.approve!).to eq("approved")
end
end

đŸ’» Model Logic Based on Tests

class LoanApplication < ApplicationRecord
def approved?
age >= 21 && income.to_i >= emi.to_i * 3
end
def approve!
approved? ? "approved" : "rejected"
end
end

✅ Results of Using TDD

  • Over 95% test coverage across models and API endpoints
  • Zero production crashes in the first six months post-launch
  • Developers onboarded faster by reading test specs
  • Confident deploys through GitHub Actions with full test runs

This startup scaled from 1 to 10 engineers while maintaining code quality thanks to a strong TDD foundation in their Rails backend.

TDD Interview Questions and Answers with Examples

  1. Q1. What is TDD and why is it important in Rails?
    A: TDD (Test-Driven Development) is a software development approach where you write tests before writing the actual code. In Rails, TDD ensures clean, bug-free, and maintainable code.

    Example:
    Write a test to validate email presence in a User model:
    it "is invalid without an email" do
    user = User.new(email: nil)
    expect(user).not_to be_valid
    end
  2. Q2. What are the main steps in a TDD cycle?
    A: Red → Green → Refactor.
    • Red: Write a failing test.
    • Green: Write code to make the test pass.
    • Refactor: Improve the code while keeping tests green.
  3. Q3. What tools do you use for TDD in Rails?
    A: Common tools include:
    • RSpec – for writing tests
    • FactoryBot – for test data setup
    • Shoulda Matchers – for common validations
    • Capybara – for integration/system tests
  4. Q4. How do you test associations in models?
    A: Using RSpec and shoulda-matchers:
    describe User do
    it { should have_many(:posts) }
    end
  5. Q5. How do you test controller actions in TDD?
    A: Using request specs or controller specs (deprecated):
    describe "GET /posts" do
    it "returns http success" do
    get "/posts"
    expect(response).to have_http_status(:success)
    end
    end
  6. Q6. What’s the difference between unit, integration, and system tests?
    A:
    • Unit tests: Test one model or method in isolation
    • Integration tests: Test how multiple parts (e.g., controllers + models) work together
    • System tests: Simulate user behavior via UI (Capybara)
  7. Q7. How can TDD help with refactoring?
    A: With tests in place, you can safely refactor code. If tests still pass after refactoring, your changes didn’t break functionality.

    Example: You refactor `full_name` method:
    def full_name
    [first_name, last_name].compact.join(" ")
    end
    Tests confirm it still behaves the same.
  8. Q8. Should you test private methods?
    A: No, you should only test public behavior. If private logic needs tests, refactor it into a separate class.
  9. Q9. What are some signs of poorly written tests?
    A:
    • Too tightly coupled to implementation
    • Hard to understand or redundant
    • Break with small code changes
    • Don’t test edge cases or business rules
  10. Q10. What’s the biggest challenge in using TDD?
    A: The initial learning curve and discipline to write tests first. Also, knowing what to test and what to skip takes experience.

Best Practices for TDD in Rails

  • 1. Write tests before writing production code
    This ensures you only write the code that is needed to fulfill a requirement.
  • 2. Keep your tests fast and focused
    Unit tests should run quickly. If a test takes too long, isolate external dependencies.
  • 3. Use FactoryBot for clean test data
    Avoid manually setting attributes every time. Define factories to reduce duplication.
    FactoryBot.define do
    factory :user do
    email { "[email protected]" }
    password { "password123" }
    end
    end
  • 4. Start with model tests
    Test validations, associations, and custom logic in models before jumping to controllers or views.
  • 5. Cover edge cases and failure scenarios
    Don’t just test happy paths. Test invalid inputs, exceptions, and unexpected behavior.
  • 6. Use descriptive test names
    Clear test names act as documentation for other developers.
    it "returns full name of user" do ... end
  • 7. Group related tests with describe and context
    Structure your test files for readability and logical separation.
    describe User do
    context "when email is missing" do
    it "is invalid" do
    ...
    end
    end
    end
  • 8. Use `let` and `subject` properly
    These help reduce duplication and make your specs cleaner.
  • 9. Don’t test implementation details
    Focus on public behavior and outcomes — not private methods or internal logic.
  • 10. Integrate with CI pipelines
    Make your test suite run on every pull request using tools like GitHub Actions or GitLab CI.

TDD vs BDD: Definitions, Differences, and Pros & Cons

🔍 Definitions

  • Test-Driven Development (TDD): Write tests before implementation, focusing on internal code correctness.
  • Behavior-Driven Development (BDD): Write scenarios describing the behavior of the application from the end user’s perspective, often using plain language.

⚖ TDD vs BDD: Key Differences

AspectTDDBDD
FocusCode correctnessUser behavior
AudienceDevelopersDevelopers, testers, business stakeholders
SyntaxRSpec, MinitestCucumber, RSpec (with feature syntax)
LanguageTechnical (e.g., `expect(model).to be_valid`)Plain English (e.g., Given/When/Then)
Test TypeUnit, integrationAcceptance, feature, end-to-end

✅ Pros and ❌ Cons

ApproachProsCons
TDD
  • Improves code quality and design
  • Ensures high test coverage
  • Easy to refactor with confidence
  • Faster feedback for developers
  • Can miss business logic clarity
  • Tests may be too low-level
  • Less communication with non-devs
BDD
  • Clear alignment with business requirements
  • Readable by non-technical stakeholders
  • Reduces misunderstandings early
  • Encourages collaboration across roles
  • More setup and tooling overhead
  • Tests may run slower
  • Overkill for small teams or simple apps

📌 When to Use What?

  • Use TDD for: APIs, service layers, libraries, model logic, performance-critical code
  • Use BDD for: User flows, stakeholder collaboration, cross-functional teams, product discovery
  • Use both together: BDD defines *what* should happen, TDD defines *how* it’s implemented

TDD: When to Use It and When You Might Skip It

✅ Scenarios Where You Should Use TDD

  • 1. Financial Transactions (e.g., eCommerce checkout, payment gateway):
    Bugs here can cause real money loss. TDD helps prevent logic errors in discounts, taxes, or totals.
    it "applies coupon discount correctly" do
    order = Order.new(subtotal: 100, coupon: Coupon.new(percent_off: 10))
    expect(order.total_price).to eq(90)
    end
  • 2. APIs with Business Rules:
    TDD ensures request/response behavior matches requirements, especially for authentication or data validation.
    it "rejects signup if email is missing" do
    post "/api/signup", params: { password: "secret" }
    expect(response.status).to eq(422)
    end
  • 3. Core Models with Validations and Relations:
    TDD is great for models like User, Invoice, Task, etc.
  • 4. Long-Term or Team Projects:
    When multiple developers are working together, TDD ensures safety and clarity. It protects against regressions.
  • 5. Background Jobs (e.g., sending emails, syncing data):
    You want to ensure jobs perform correctly and handle retries or failures gracefully.

❌ Scenarios Where TDD Might Be Skipped

  • 1. Throwaway Code or Prototypes:
    You’re building a demo or one-time feature that may never ship. Speed is prioritized over stability.
  • 2. Static Pages or Simple HTML Views:
    A Terms & Conditions page or FAQ with no logic — no need for tests unless you have a CMS-driven setup.
  • 3. Complex Frontend Layouts (Visual-Only):
    If you’re designing a layout with Tailwind/Bootstrap and no data logic, snapshot or visual testing is better than TDD.
  • 4. Spike Solutions / Exploratory Code:
    You’re exploring an idea (like trying a new gem). You can add tests later once the idea solidifies.
  • 5. CRUD Scaffolds That Will Be Replaced:
    When using Rails generators just to build early views, it’s okay to delay tests until real logic is added.

Tip: Even if you skip TDD for now, write at least model specs and request specs for key logic later. You can layer tests progressively.

Alternatives to TDD

  • BDD: Behavior Driven Development — focus on user behavior.
  • ATDD: Acceptance Test Driven Development — focus on business rules.
  • Manual Testing: Practical but risky without automation.

Real-World Case Study

Basecamp by 37signals

Basecamp uses TDD throughout their Rails product lifecycle. They maintain a large RSpec test suite and rely on CI to catch regressions before deploys.

  • Thousands of model and request specs
  • Team collaboration improves through test clarity
  • Tests run automatically on GitHub Actions for every pull request


Learn more about Rails Testing Framework setup

31 thoughts on “How to Implement TDD in Rails – Step-by-Step with Examples”

Comments are closed.

Scroll to Top