Adding tests to legacy Rails code isn’t about hitting an arbitrary coverage target; it’s about strategically creating a safety net for your most critical business logic. This process lets you freeze the application’s current behavior—good or bad—so you can refactor and add features without introducing new bugs.

Since 2009, USEO has been parachuted into complex, mission-critical Rails applications that lack any form of automated testing. These systems, often powering established businesses like Yousty (a major Swiss portal we’ve supported since 2012), are too valuable to fail but too risky to change. Our approach isn’t a “big bang” rewrite; it’s a methodical process of building confidence. This is how we do it.

Phase 1: The Characterization

The first step is to understand and document the system’s current behavior. We use characterization tests (also known as approval tests) to lock down the output of key components. The goal is not to judge the code or fix bugs, but to capture its existing state. This gives you a baseline. If a future change alters this behavior, your tests will fail, alerting you immediately.

You’re creating a software fingerprint. RSpec is our tool of choice here. We start by targeting high-value, low-dependency areas—often complex business calculations or data transformations hidden in models or service objects. You write a test that calls the method with known inputs and asserts that the output is exactly what the system produces right now, even if that output seems incorrect.

Synthetic Engineering Context: Freezing a Legacy Report Generator

Imagine an untamed SalesReport model with a method that generates a complex hash for a monthly summary. It’s been running for years, but no one knows exactly how it works. Your first test will simply capture its output.

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

RSpec.describe SalesReport, type: :model do
  describe '#generate_monthly_summary' do
    it 'produces the known, existing summary structure' do
      # Setup: Create the necessary data in a controlled way
      # For now, we might rely on existing records or fixtures
      company = Company.find_by(name: 'Legacy Corp')
      report_date = Date.new(2024, 4, 15)
      
      # Execution: Run the method we want to characterize
      report = SalesReport.new(company: company, date: report_date)
      summary_hash = report.generate_monthly_summary

      # Verification: Assert against the known output (the "fingerprint")
      # This hash is what the method currently returns. We copy/paste it here.
      expect(summary_hash).to eq({
        month: "April 2024",
        total_revenue: 10543.21,
        top_product_id: 112,
        region_sales: { "north" => 4500.10, "south" => 6043.11 },
        flags: ["needs_review"]
      })
    end
  end
end

This test doesn’t say the calculation is right. It says the calculation is what it is. Now you can’t accidentally break it.

Phase 2: Finding Seams & Introducing Test Doubles

Legacy code is often a tangled web of dependencies. A simple model method might trigger database queries, fire off external API calls, and read from global configuration. To test it in isolation, you need to find “seams”—places where you can break these dependencies and substitute them with predictable stand-ins called test doubles.

This is where tools like factory_bot and webmock become indispensable.

  • factory_bot lets you create test data (like users, products, orders) in a clean, repeatable way, avoiding reliance on a fragile, ever-changing development database.
  • webmock allows you to intercept outgoing HTTP requests and return canned responses. This isolates your tests from unreliable third-party APIs, making your test suite faster and more deterministic.

Identifying seams is an art. Look for HTTParty.get, Net::HTTP.start, Stripe::Charge.create, or any call that crosses the boundary of your application. These are your primary targets for isolation.

Synthetic Engineering Context: Isolating a User Onboarding Workflow

Consider a User model with an after_create callback that subscribes the user to a newsletter via an external API and sets a flag based on the response. Testing this directly is slow and brittle. Instead, we use a seam.

First, set up your factory:

# spec/factories/users.rb
FactoryBot.define do
  factory :user do
    email { "test.user@example.com" }
    first_name { "Test" }
    last_name { "User" }
  end
end

Next, use webmock in your spec to stub the external API call:

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

RSpec.describe User, type: :model do
  describe 'after_create#subscribe_to_newsletter' do
    it 'subscribes the user and sets the synced flag on success' do
      # Setup: Stub the external HTTP request
      stub_request(:post, "https://api.newsletter.com/subscribers")
        .with(body: { email: "new.user@example.com" }.to_json)
        .to_return(status: 201, body: { id: "sub_123" }.to_json)

      # Execution: Create a user using the factory
      user = FactoryBot.create(:user, email: "new.user@example.com")

      # Verification: Check the outcome within our application
      expect(user.newsletter_synced_at).not_to be_nil
      expect(user.newsletter_subscription_id).to eq("sub_123")
    end
  end
end

Now your test runs in milliseconds, is 100% reliable, and only validates your application’s logic, not the external service.

Phase 3: Building The Safety Net

With characterization tests in place and dependencies isolated, you can build the final safety net: an automated testing pipeline. The goal is to make running tests a non-negotiable part of every single code change. This is typically done with a Continuous Integration (CI) service like GitHub Actions.

A simple configuration can be set to run your RSpec suite on every git push. If a test fails, the push is blocked, preventing the regression from ever reaching production. This feedback loop is critical.

To measure progress, we introduce a code coverage tool like SimpleCov. It shows what percentage of your code is exercised by your tests. But be pragmatic: start with a low threshold (e.g., 20%). The goal isn’t to hit a vanity number, but to ensure that the coverage percentage never decreases. This guarantees that all new code requires tests and that old code is gradually brought under test as it’s touched. It’s a ratchet that only tightens.

Synthetic Engineering Context: CI and Coverage Setup

First, configure SimpleCov at the very top of your spec/spec_helper.rb:

# spec/spec_helper.rb
require 'simplecov'
SimpleCov.start 'rails' do
  add_filter '/bin/'
  add_filter '/db/'
  add_filter '/spec/' # Don't include tests in coverage

  # Set a minimum coverage percentage. The build will fail if it drops below this.
  # Start low and ratchet it up over time.
  minimum_coverage 25
end

Next, add a basic GitHub Actions workflow file:

# .github/workflows/ci.yml
name: Rails CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Ruby
        uses: ruby/setup-ruby@v1
        with:
          ruby-version: '3.2.2'
          bundler-cache: true
      - name: Run tests
        run: bundle exec rspec

With this in place, you have a foundational safety net. Your team can now begin to refactor with confidence, knowing that any deviation from the established behavior will be caught automatically. This is the foundation upon which stable, modern systems like Versus (launched in 2025) are built.

Need a Rails testing safety net?

Tackling a legacy codebase is daunting. If you need an experienced partner to help build your testing safety net and unlock your application’s potential, we can help.

Learn more about our Code Audit & Implementation service.