Your legacy Rails app has no tests. Every deploy is a gamble. A change in one model breaks a controller three files away, and you only discover it when a customer reports the bug. Regression tests are the fix, but adding them to an application that was never designed for testability requires a specific approach.

Why do legacy Rails apps resist testing?

Legacy applications accumulate characteristics that make testing difficult:

  • Tightly coupled code where models call controllers, controllers query databases directly, and business logic lives in callbacks
  • Missing documentation so no one knows what the correct behaviour actually is
  • Outdated dependencies that conflict with modern testing tools
  • No test database configuration because the original developers never set one up
  • Hardcoded external service calls that fail when run outside production

The goal is not to achieve 100% coverage overnight. It is to build a safety net that grows with every code change, catching regressions before they reach production.

How do you audit a legacy Rails codebase for testability?

Start with rake stats to get an overview: lines of code, controller count, model count, and existing test ratio. Most legacy apps have a code-to-test ratio well below 1:1.

Audit checklist:

  1. Identify critical paths - Which features generate revenue? Which handle sensitive data? These get tested first.
  2. Map dependencies - Check Gemfile.lock for deprecated or abandoned gems. Run bundle install and note all warnings.
  3. Find coupling hotspots - Look for models with 20+ methods, controllers with business logic, and callbacks that trigger side effects across the application.
  4. Document undocumented behavior - Talk to the team. The person who has maintained the app longest knows which code paths are fragile.
  5. Check database integrity - Look for missing foreign key constraints, orphaned records, and denormalized data that tests will need to account for.

“We perfectly understand the challenges of older versions of Rails and can breathe new life into key systems, minimising the risk of downtime.” — USEO

How do you set up a testing environment for an untested app?

Create config/environments/test.rb with test-specific settings. Use a dedicated test database that mirrors the production database engine (PostgreSQL, MySQL, etc.).

Essential setup steps:

  1. Configure transactional fixtures or database_cleaner for consistent test state
  2. Stub external service calls so tests run independently
  3. Set up environment variables for test-specific configuration
  4. Configure logging and error reporting to capture failures without polluting production logs
  5. Set config.i18n.default_locale to match your production default

RSpec vs Minitest for legacy projects

AspectRSpecMinitest
SyntaxExpressive, BDD-styleMinimal, assertion-based
Setup for legacy appsMore initial configurationBuilt into Rails, zero setup
Community resourcesLarger ecosystem of matchers and extensionsSmaller but sufficient
Readability for complex testsBetter for describing business logicBetter for simple unit tests
Best forLegacy apps with complex, undocumented behaviorLegacy apps where speed of setup matters

For most legacy projects, RSpec is the better choice. Its descriptive syntax turns tests into documentation, which is exactly what undocumented legacy code needs.

# Install RSpec
bundle add rspec-rails
rails generate rspec:install

Where should you write the first tests?

Prioritize by business impact, not code complexity. Test the features that would cause the most damage if they broke.

Priority matrix:

PriorityFeature categoryExamples
CriticalRevenue-generating flowsPayment processing, subscription management
CriticalAuthentication/authorizationLogin, session management, permission checks
HighCore business logicData calculations, report generation, status transitions
HighData integrity operationsImports, exports, batch updates
MediumUser-facing workflowsRegistration, profile updates, search
LowerAdmin/internal toolsDashboards, configuration screens

For each critical feature, write both the happy path and the most likely failure scenario. Two tests per feature is infinitely better than zero.

How do you write unit and integration tests for legacy code?

Unit tests first

Start with models and service objects. Test the methods that perform calculations, validate data, or make decisions:

# spec/models/invoice_spec.rb
RSpec.describe Invoice do
  describe "#total_with_tax" do
    it "calculates total with Swiss VAT" do
      invoice = build(:invoice, subtotal: 1000.00, tax_rate: 8.1)
      expect(invoice.total_with_tax).to eq(1081.00)
    end

    it "handles zero subtotal" do
      invoice = build(:invoice, subtotal: 0, tax_rate: 8.1)
      expect(invoice.total_with_tax).to eq(0)
    end
  end
end

Use descriptive test names that document the expected behavior. When the test fails in six months, the name should tell the developer what the code is supposed to do.

Integration tests for workflows

After unit tests cover individual methods, write integration tests that simulate real user journeys:

# spec/features/checkout_spec.rb
RSpec.describe "Checkout process" do
  it "completes an order from cart to confirmation" do
    user = create(:user)
    product = create(:product, price: 49.90)

    sign_in user
    visit product_path(product)
    click_button "Add to cart"
    click_link "Checkout"
    fill_in "Card number", with: "4242424242424242"
    click_button "Place order"

    expect(page).to have_content("Order confirmed")
    expect(Order.last.total).to eq(49.90)
  end
end

Include failure scenarios: what happens when payment fails? When stock runs out? When the session expires mid-checkout?

What do you do when tests fail on legacy code?

Failures are expected. Legacy apps have hidden bugs that only surface during testing. Categorize each failure:

  1. Genuine bug - The code does something wrong. Fix it and keep the test.
  2. Test environment issue - Missing stub, wrong database state, configuration mismatch. Fix the test setup.
  3. Intentional behavior - The code works as designed, even if the design seems wrong. Document the behavior in the test and move on.
  4. Deep architectural problem - The failure reveals tightly coupled code that cannot be tested in isolation. Note it for future refactoring.

Do not modify tests to match broken behavior unless you have confirmed the behavior is intentionally correct. The test documents what should happen, not what currently happens.

Track progress with metrics:

  • Test coverage percentage (SimpleCov)
  • Ratio of passing to failing tests
  • Number of critical paths covered vs. total critical paths

How do you automate regression tests with CI/CD?

Manual test runs get skipped under deadline pressure. CI makes testing automatic and non-negotiable.

# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ruby/setup-ruby@v1
        with:
          bundler-cache: true
      - run: bundle exec rails db:prepare
        env:
          RAILS_ENV: test
      - run: bundle exec rspec

Automation strategy:

  • Run unit tests on every commit (fast feedback)
  • Run integration tests on pull requests (thorough validation)
  • Schedule full end-to-end tests nightly (comprehensive but slow)

How do you keep the test suite relevant over time?

A test suite rots when tests are not updated alongside code changes. Three practices prevent this:

  1. Update tests with every feature change. If you modify a method, update its tests. If you remove a feature, delete its tests. Stale tests create false confidence.

  2. Monitor coverage with SimpleCov. Coverage reports highlight gaps in critical areas. Set a coverage floor that ratchets upward: never allow coverage to decrease.

  3. Document test cases. Each test should explain what business requirement it protects. Include context about edge cases and historical bugs. This documentation helps new developers understand why tests exist.

Practical Implementation: The USEO Approach

Adding regression tests to a legacy Rails app is not a technical problem alone. It is a project management challenge: how do you build test coverage without halting feature development?

On the Yousty HR portal (13-year partnership), we inherited an application with minimal test coverage and complex Swiss employment law business rules encoded in models and service objects. The challenge: these rules were undocumented, and the only specification was the running application itself.

Our approach was “characterization testing” first. Before writing tests that assert correct behavior, we wrote tests that captured current behavior, regardless of whether that behavior was correct. This gave us a safety net immediately. When we later discovered bugs through user reports or code review, we updated the characterization tests to reflect the corrected behavior.

The testing strategy followed feature development rather than being a separate workstream:

  • Every bug fix required a regression test proving the bug was fixed
  • Every new feature required tests as part of the definition of done
  • Refactoring was only permitted when adequate test coverage existed for the affected code

Within 18 months, test coverage grew from under 15% to over 85% without a single “test writing sprint” that paused feature work.

For Triptrade (travel MVP), the challenge was different. As a new project built on tight deadlines, the risk was not legacy code but technical debt accumulation. We established the testing discipline from the first commit: no PR merged without tests. This prevented the Triptrade codebase from ever becoming a legacy app that resists testing.

Testing patterns for common legacy Rails problems:

Legacy problemTesting approachTools
Fat models (50+ methods)Characterization tests for existing behavior, then extract and test service objectsRSpec, FactoryBot
Callbacks with side effectsIntegration tests covering the full callback chainRSpec request specs
Missing validationsUnit tests that expose invalid states the app currently allowsshoulda-matchers
N+1 queriesPerformance tests with query countingbullet gem, custom matchers
Hardcoded external callsWrap in service objects, stub in testswebmock, vcr

The most important lesson from both projects: perfect test coverage is not the goal. Targeted coverage of critical business logic, combined with CI enforcement, delivers 90% of the stability benefit at a fraction of the cost.

FAQs

How do you decide which features to test first in a legacy Rails app?

Start with features that generate revenue, handle sensitive data, or have caused production incidents in the past. Payment processing, authentication, and core business calculations are almost always the right starting point. Collaborate with stakeholders to identify what would cause the most business damage if it broke after a deploy.

What are the common challenges with RSpec in legacy Rails apps?

Tightly coupled code makes isolation difficult. Fat models with many callbacks create complex setup requirements. Missing test infrastructure (no factories, no test database config) requires upfront investment. Address these by using characterization tests that capture current behavior first, then refactor toward testable code incrementally.

How do you build test coverage without stopping feature development?

Require tests for every new feature and every bug fix. Do not create separate “testing sprints.” Coverage grows naturally as the team works on the codebase. Use SimpleCov to track progress and set a ratcheting coverage floor that prevents regression. Within a year, most teams reach 70-80% coverage through this organic approach.