Embracing the Cycle: A Pragmatic Look at TDD in Ruby on Rails

By Ryan Wentzel
5 Min. Read
#AI#ruby-on-rails#TDD#RSpec#testing
Embracing the Cycle: A Pragmatic Look at TDD in Ruby on Rails

Table of Contents

The Rails Testing Ecosystem: RSpec vs. Minitest

Minitest ships with Rails. It's fast, lightweight, uses plain Ruby assertion syntax, and the core Rails team uses it daily. For many teams it's more than enough. Still, a large slice of the community reaches for RSpec, and it's worth understanding why.

RSpec is a Behavior-Driven Development framework built around an expressive DSL that reads almost like English. This is where the "tests as documentation" idea really earns its keep. You're not just asserting that a == b — you're describing how a piece of your system is supposed to behave.

# An example of RSpec's expressive DSL
RSpec.describe OrderProcessor do
  context "when the user has sufficient funds" do
    it "processes the transaction successfully" do
      # setup, action, assertion
    end
  end
end

Most production Rails shops pair RSpec with a few near-standard companions: FactoryBot for test data, Shoulda Matchers for one-line validation and association specs, VCR or WebMock for stubbing third-party APIs, and Capybara for system specs that drive a real browser. Together they form the de facto SaaS testing stack.

Because Rails leans heavily on MVC, TDD also nudges you toward cleaner separation of concerns. The friction of testing fat models and fat controllers tends to push logic out into Service Objects, POROs (Plain Old Ruby Objects), Form Objects, or Concerns — exactly where it belongs once your app grows past the prototype stage.

Dissecting the Red–Green–Refactor Cycle

TDD's core loop is a tight micro-iteration: Red, Green, Refactor. Here's what that looks like building a feature in Rails.

1. Red — Write the failing test

You write the test before any implementation. This forces you to think about the interface of your object before you worry about its internals.

Say we're building a User model that needs age verification — a common requirement for SaaS products with regulatory or content restrictions.

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

RSpec.describe User, type: :model do
  describe "validations" do
    it "is invalid when the user is under 18" do
      user = User.new(date_of_birth: 15.years.ago)
      user.valid?

      expect(user.errors[:date_of_birth])
        .to include("you must be at least 18 years old")
    end
  end
end

Run bundle exec rspec spec/models/user_spec.rb. It fails. That's the Red, and it's the point — you've just proven the test can detect the absence of the behavior.

2. Green — Make it pass

Your only job now is to get to green. Don't optimize, don't generalize, don't reach for the elegant abstraction. Write the smallest thing that works.

# app/models/user.rb
class User < ApplicationRecord
  validate :must_be_eighteen

  def must_be_eighteen
    return if date_of_birth.blank?

    if date_of_birth > 18.years.ago
      errors.add(:date_of_birth, "you must be at least 18 years old")
    end
  end
end

Run the suite. Green.

3. Refactor — Clean it up

With a passing test as your safety net, you're free to improve the design. Maybe you extract the rule into a custom validator so other models can reuse it. Maybe you just tighten the syntax for clarity.

# app/models/user.rb
class User < ApplicationRecord
  validates :date_of_birth, presence: true
  validate  :user_meets_minimum_age

  private

  def user_meets_minimum_age
    return if date_of_birth.blank?
    return unless under_age?

    errors.add(:date_of_birth, "you must be at least 18 years old")
  end

  def under_age?
    date_of_birth > 18.years.ago
  end
end

If you break something, RSpec tells you immediately. That's the whole bargain: tests buy you the freedom to refactor aggressively.

Why This Matters Specifically for SaaS

Years ago, David Heinemeier Hansson — Rails' creator — sparked a memorable debate by declaring "TDD is dead." His point was that dogmatically unit-testing every method leads to brittle tests and design damage: mocks piled on mocks, indirection for its own sake.

That critique still stands. But pragmatic TDD, as opposed to the religious version, remains enormously valuable for SaaS teams shipping continuously:

  • Design feedback. If a test is painful to write, the code under test is usually doing too much, or it's coupled to something it shouldn't know about. TDD turns that pain into an early warning system, pushing you toward smaller classes, single responsibilities, and dependency injection — properties that pay off when a feature needs to be swapped, A/B tested, or deprecated.
  • Living documentation. Ruby is dynamic. Six months from now, when a new engineer asks "what does this service object actually take?", a well-written spec answers in a way no stale wiki page ever will.
  • Fearless refactoring. SaaS products live for years. Rails upgrades, Ruby version bumps, gem migrations, and the occasional rewrite of a legacy module are routine. With coverage, your team can modernize legacy code, swap dependencies, and merge to main on a Friday afternoon.
  • CI as a release gate. For teams practicing continuous deployment, the test suite is the release process. Every green build is a candidate for production. TDD keeps that suite trustworthy enough to actually ship on.
  • Multi-tenant safety. Most SaaS apps are multi-tenant in some form, and tenant-isolation bugs are the kind of thing you really, really don't want to discover in production. Tests are the cheapest place to catch them.

The Takeaway

Rails won't force you into TDD. You can scaffold a resource and start piling logic into a controller this afternoon. For a weekend project, that's fine.

For a SaaS product that needs to survive its second year, its third engineer, and its fifth Rails upgrade, the Red–Green–Refactor cycle stops looking like overhead and starts looking like leverage. Treat tests as a design tool rather than an end-of-sprint chore, and they'll quietly shape a codebase you actually want to keep working in.

For a SaaS team, that shift isn't just an engineering nicety. It's what makes weekly releases boring, on-call rotations quiet, and the next big refactor actually possible.

Share Your Thoughts

Found this article helpful? Share it with your network.

Get in Touch
Trusted by teams using
NetflixOracleFigmaCoinbaseDellServiceNowAppleDeloitteNikeAWSJPMorgan ChaseT-MobileAtlassianBoschStripeL'OréalDatadogMicrosoftPalantirHPRobinhoodEYSonyCanvaVisaAutoCADDiscordBell HelicopterAdobeCharles SchwabE*TRADENVIDIAGoogleJohnson & JohnsonFidelityClaudeMastercardIntuitBoeingAT&TShopifyPwCOpenAIKPMGIBMDatabricksSalesforceGitHubAmerican ExpressWorkdayMailerSend