Skip to main content
  1. Posts/

The Full Test Suite

·1448 words·7 mins
Photograph By Ildefonso Polo
Blog Ruby Ruby on Rails
Table of Contents
Rails for the TypeScript Engineer - This article is part of a series.
Part 6: This Article

The Tests That Click Buttons
#

In the last testing post , I covered RSpec, FactoryBot, and WebMock — the tools for testing business logic, generating test data, and stubbing HTTP calls. That post ended with a workflow: write the test, run it, red, green, refactor. Good fundamentals.

But there’s a whole category of bugs those tests can’t catch. The kind where your model is valid, your endpoint returns 200, your service object works perfectly — and the user still can’t submit the form because a JavaScript event listener isn’t wired up, or a Turbo frame isn’t updating, or the button text says “Submit” but the CSS hides it on mobile.

You need tests that open a real browser and click things. Enter Capybara and Selenium.

The Testing Pyramid
#

Before diving into the tools, here’s the mental model that decides when to use what:

        /  System  \        ← Few (5-15): slow, real browser, critical user flows
       /  Request   \       ← Some (20-50): test all endpoints, no browser
      /    Model     \      ← Many (50-200): fast, test all business logic

Model specs are fast (milliseconds) and test your validations, associations, and service objects. Request specs are medium speed and test HTTP endpoints end-to-end — the right status code, the right redirect, the right side effect in the database. System specs are slow (seconds per test) and test what the user actually sees and does in a browser.

The pyramid shape is intentional. Most of your tests should be model specs. System specs are expensive — write them for the flows that matter most and can’t be tested any other way.

Capybara: The Browser Whisperer
#

Capybara is a Ruby DSL for browser interaction. It doesn’t drive the browser directly — it delegates to a driver. The most common driver is Selenium WebDriver, which controls a real Chrome instance.

The setup is minimal. Rails 7+ includes both gems by default:

# Gemfile (already there in most Rails 7+ projects)
group :test do
  gem "capybara"
  gem "selenium-webdriver"
end
# spec/support/capybara.rb
RSpec.configure do |config|
  config.before(:each, type: :system) do
    driven_by :selenium, using: :headless_chrome, screen_size: [1400, 900]
  end
end

Headless Chrome runs without a visible window — faster, and it works in CI. When you’re debugging a failing test and need to see what’s happening, swap :headless_chrome for :chrome and watch the browser dance.

Writing a System Spec
#

Here’s what a system spec looks like. Compare it to the Playwright/Cypress tests you’ve written in TypeScript — the structure is nearly identical:

# spec/system/urls_spec.rb
require "rails_helper"

RSpec.describe "URL Shortening", type: :system do
  it "user creates a short URL" do
    visit new_url_path

    fill_in "URL", with: "https://example.com"
    click_on "Shorten"

    expect(page).to have_content("URL was successfully created")
    expect(page).to have_content("example.com")
  end

  it "user sees validation error for empty URL" do
    visit new_url_path
    click_on "Shorten"

    expect(page).to have_content("can't be blank")
  end

  it "user clicks a short URL and gets redirected" do
    url = create(:url, target_url: "https://example.com")
    visit url_path(url)

    click_link url.short_code
    expect(page).to have_current_path(url.target_url)
  end
end

If you’ve written Playwright tests, this reads like a slightly different dialect of the same language:

// Playwright equivalent
test("user creates a short URL", async ({ page }) => {
  await page.goto("/urls/new");
  await page.fill('[name="url[target_url]"]', "https://example.com");
  await page.click('button[type="submit"]');
  await expect(page.locator("body")).toContainText(
    "URL was successfully created",
  );
});

The Capybara version is more readable because it uses label-based selectors (fill_in "URL") instead of CSS selectors. This is also more resilient — if you rename a CSS class, the Capybara test still works as long as the label text stays the same.

The Auto-Wait That Saves Your Sanity
#

If you’ve ever written await page.waitForSelector() in Playwright or cy.wait() in Cypress, you’ll appreciate this: Capybara waits automatically. When you write:

expect(page).to have_content("URL was successfully created")

Capybara will retry for up to 2 seconds, checking repeatedly until the content appears or the timeout expires. This handles Turbo responses, JavaScript rendering, and any other async behavior without explicit waits.

For slower operations:

expect(page).to have_content("Processing complete", wait: 5)

No sleep. Ever. If you’re writing sleep in a Capybara test, something else is wrong.

Shoulda Matchers: One-Liners That Replace Five
#

In the previous post , testing a validation looked like this:

it "requires target_url" do
  url = build(:url, target_url: nil)
  expect(url).not_to be_valid
  expect(url.errors[:target_url]).to include("can't be blank")
end

Four lines. Clear, but repetitive when you have 10 validations. Shoulda Matchers compresses this:

it { is_expected.to validate_presence_of(:target_url) }

One line. Same coverage. Behind the scenes, Shoulda Matchers builds an object, sets the field to nil, checks validity, and verifies the error message — exactly what you’d write manually.

Here’s what a full model spec looks like with Shoulda Matchers:

RSpec.describe Url, type: :model do
  # Test Rails declarations with one-liners
  describe "validations" do
    it { is_expected.to validate_presence_of(:target_url) }
    it { is_expected.to validate_uniqueness_of(:short_code) }
    it { is_expected.to validate_length_of(:short_code).is_equal_to(6) }
  end

  describe "associations" do
    it { is_expected.to have_many(:visits).dependent(:destroy) }
  end

  # Test custom logic with explicit tests
  describe "short code generation" do
    it "generates a 6-character alphanumeric code" do
      url = create(:url)
      expect(url.short_code).to match(/\A[a-zA-Z0-9]{6}\z/)
    end
  end
end

The rule of thumb: Shoulda Matchers for Rails declarations (validations, associations, indexes), explicit tests for custom logic (callbacks, methods, business rules). If you’re testing something you declared in the model with validates or has_many, there’s probably a one-liner for it.

TypeScript doesn’t have an equivalent because Jest doesn’t know about your ORM. You’d write each validation test manually. This is one area where Rails’ convention-over-configuration pays off in testing too.

SimpleCov: The Coverage Enforcer
#

You know that feeling when you write tests, feel good about your coverage, and then discover three months later that an entire service object has zero tests? SimpleCov prevents that.

# spec/rails_helper.rb — must be the FIRST thing loaded
require "simplecov"
SimpleCov.start "rails" do
  enable_coverage :branch    # Track if/else paths, not just lines
  minimum_coverage 80        # Fail the suite if coverage drops below 80%
end

After running bundle exec rspec, SimpleCov generates an HTML report at coverage/index.html. Open it and you see every file, color-coded:

  • Green lines were executed during tests
  • Red lines were never touched
  • Branch indicators show if both paths of an if/else were tested

The minimum_coverage 80 line is the enforcer. If your coverage drops below 80%, the test suite fails — even if every test passes. This means you can’t merge code that reduces test coverage without deliberately writing tests for it.

Two Types of Coverage
#

Line coverage asks: did each line execute at least once? Branch coverage asks: did each if/else execute both paths?

def call(ip_address)
  return default_result if private_ip?(ip_address)  # Line: ✅  Branch: both paths?
  fetch_geolocation(ip_address)                       # Line: ✅
rescue SocketError
  default_result                                      # Line: ❌ (no test triggers this)
end

You can have 90% line coverage and still miss critical error paths. Branch coverage catches that. Jest has this built in with --coverage. SimpleCov needs enable_coverage :branch explicitly.

The TypeScript equivalent is Istanbul/c8 with Jest:

{
  "jest": {
    "coverageThreshold": {
      "global": {
        "lines": 80,
        "branches": 70
      }
    }
  }
}

Same concept, different config file.

The Full Stack
#

Here’s how all the testing tools fit together in a Rails project:

ToolRoleTypeScript Equivalent
RSpecTest frameworkJest
FactoryBotTest data generationFishery / manual builders
Shoulda MatchersValidation/association one-liners(no equivalent)
WebMockHTTP request stubbingMSW / nock
Capybara + SeleniumBrowser automationPlaywright / Cypress
SimpleCovCode coverage enforcementIstanbul / c8

And the flow in practice:

Model specs (fast, many)
  → RSpec + FactoryBot + Shoulda Matchers
  → Test: validations, associations, service objects, business logic

Request specs (medium, some)
  → RSpec + FactoryBot + WebMock
  → Test: HTTP endpoints, status codes, redirects, JSON responses

System specs (slow, few)
  → RSpec + FactoryBot + Capybara + Selenium
  → Test: user creates account, user submits form, user navigates flow

Coverage
  → SimpleCov runs across ALL spec types
  → Enforces 80% minimum before CI passes

The Biggest Difference From TypeScript
#

In a TypeScript project, you’d install Jest, then Playwright, then MSW, then Istanbul — each with its own config file, its own setup, its own documentation. They work together, but you’re the glue.

In Rails, rspec-rails gives you model, request, and system spec types out of the box. FactoryBot integrates with one line in rails_helper.rb. Shoulda Matchers configures itself. Capybara auto-detects the test server. SimpleCov just needs to be required first. The ecosystem assumes you’re using all of these together, and the integration shows.

This is the same pattern from the convention post — Rails makes decisions so you don’t have to. In testing, those decisions mean less configuration and more actual testing.

In The Switching Cost , I covered what was harder than expected and what was easier about learning Rails. The testing stack was firmly in the “easier” column — and now you can see why.

Aaron Yong
Author
Aaron Yong
Building things for the web. Writing about development, Linux, cloud, and everything in between.
Rails for the TypeScript Engineer - This article is part of a series.
Part 6: This Article

Related

Testing in a New Language
·966 words·5 mins
Photograph By Girl with red hat
Blog Ruby Ruby on Rails
RSpec vs Jest, FactoryBot vs fixtures, and Rails security that comes free
The Switching Cost
·1106 words·6 mins
Photograph By Mukund Nair
Blog Ruby Ruby on Rails
What was harder than expected, what was easier, and what I’d tell myself before learning Rails
The Rails Way
·918 words·5 mins
Photograph By Aleksandr Popov
Blog Ruby Ruby on Rails
Service objects, Hotwire, ERB, and where Rails and Node.js disagree about how web apps should work