rubyguides

Ruby Test Coverage with SimpleCov: Measure and Improve Your Tests

Test coverage tells you what percentage of your code actually runs during tests. High coverage doesn’t guarantee good tests, but low coverage is a warning sign. SimpleCov makes measuring Ruby coverage straightforward.

intro context

Coverage works best when you treat it as a signal, not a prize. A report that shows missing lines tells you where to look next, but it does not tell you whether the missing code matters. That is why SimpleCov is most useful when you combine it with judgment.

The main job of a coverage report is to point out blind spots. Once you can see which files or branches were never exercised, you can decide whether to delete dead code, add a test, or accept the gap because it is intentional.

That way of thinking keeps the report useful even when the percentage changes slowly. The real value comes from the conversation the report starts: which files matter most, which gaps are deliberate, and which ones are worth closing next.

Why measure coverage?

Coverage helps you find code that never gets exercised:

  • Unreachable code from dead conditionals
  • Missing test cases for edge cases
  • Feature branches that were never tested

Coverage is a tool for discovering gaps, not a score to maximize. Aiming for 100% coverage often leads to meaningless tests just to tick a box.

That is a good rule to repeat in code review. The number on the report is only useful when it leads to a better test or a clearer piece of code. If it does not change the work you do next, it is probably just a vanity metric.

Setting up SimpleCov

Add SimpleCov to your Gemfile:

group :test do
  gem 'simplecov', require: false
end

Then configure it at the very top of your test helper, before any other code loads:

# test/test_helper.rb
require 'simplecov'
SimpleCov.start 'rails' do
  add_filter '/test/'
  add_filter '/config/'
  add_filter '/vendor/'
end

require_relative '../config/environment'
require 'rails/test_helper'

The start method begins coverage measurement. Calling it first ensures all code gets tracked.

That order matters because any file loaded before SimpleCov.start will never appear in the report. Starting it early keeps the report honest and makes it much easier to trust what the results are telling you.

Coverage modes

SimpleCov supports different coverage modes:

  • SimpleCov.start 'rails'; Rails app with default filters
  • SimpleCov.start 'gem_name'; For gem development
  • SimpleCov.start; Vanilla Ruby project

Run your test suite as normal. SimpleCov hooks into the Ruby Coverage module and tracks every line that executes during the run. The mode you pick determines which default filters and groups are applied, so choosing the right one saves you from manually excluding test files and vendor directories:

bundle exec rake test
# or
bundle exec rspec

After tests complete, SimpleCov generates an HTML report in coverage/.

Understanding coverage reports

Open coverage/index.html in your browser. You’ll see:

The report is easiest to read when you focus on one file at a time. Start with the files that matter most to users, then work outward. A small amount of red in a critical path is usually more important than a larger amount of red in a file that almost never changes.

Line Coverage

The percentage of executable lines that ran. Each line of code shows green (covered), red (not covered), or gray (not executable).

Branch Coverage

Tracks whether both branches of conditionals were exercised:

def status
  if approved?
    "approved"  # branch A
  else
    "pending"   # branch B
  end
end

If tests only call status when approved? returns true, branch B shows uncovered.

File List

Sorted by coverage percentage. Focus on important files with low coverage first, not just the files with the lowest numbers.

Interpreting coverage data

What’s a good coverage number?

There’s no universal target. Many teams use 80% as a starting point:

  • Below 50%: Likely significant untested functionality
  • 50-80%: Room for improvement
  • Above 80%: Decide if remaining gaps are intentional

Coverage doesn’t tell the whole story

High coverage with bad tests is worse than moderate coverage with good tests:

# Bad test that gives false confidence
def test_calculator
  assert true  # This passes but tests nothing!
end

This achieves 100% coverage but validates nothing. Review what your tests actually assert.

That is why coverage works best alongside meaningful assertions. A coverage report can tell you that code ran, but only your test names and expectations can tell you whether the behavior was worth checking.

Using coverage to guide testing

Find the Gaps

When you see red in coverage reports, ask:

  1. Is this code dead? If so, remove it.
  2. Is this an edge case I should test? Add a test.
  3. Is this error handling code? Make sure you test failure paths.

After you answer those questions, you usually know the next move. Either remove the code, add a test, or document why the gap is acceptable. That simple decision tree keeps coverage work practical instead of ceremonial.

Example: adding missing tests

Suppose you have a payment processor with several guard clauses that return early for invalid input:

class PaymentProcessor
  def charge(amount, card)
    return :invalid_amount if amount <= 0
    return :card_declined unless card.valid?
    
    # Process payment...
    :success
  end
end

The charge method has two guard clauses and one happy path. If coverage shows the card_declined branch untested, you are missing a test for the scenario where the card is invalid. Adding a dedicated test for that return value closes the gap without changing the production code:

def test_charge_declines_invalid_card
  card = Card.new(valid: false)
  result = processor.charge(100, card)
  
  assert_equal :card_declined, result
end

This test creates an invalid card, calls the same charge method, and checks the return value. Once the test is in place, running the suite again should show that branch as covered. The pattern generalizes: find the red line in the coverage report, write a test that exercises it, and confirm the color changes.

Integration with CI

Run SimpleCov in your CI pipeline to track coverage over time. CI integration turns coverage from a local curiosity into a team-wide signal that catches regressions before they merge:

# .github/workflows/ci.yml
- name: Run tests with coverage
  run: bundle exec rake test
  
- name: Upload coverage
  uses: codecov/codecov-action@v4
  with:
    files: ./coverage/coverage.xml

The Codecov action uploads your coverage data after every CI run, giving you a dashboard that shows trends over time. It also posts inline comments on pull requests to flag lines that lost coverage, which saves you from manually comparing reports.

Create a coverage badge in your README so contributors can see the current status at a glance:

[![Coverage](https://codecov.io/gh/yourorg/yourrepo/branch/main/graph/badge.svg)](https://codecov.io/gh/yourorg/yourrepo)

Filtering and grouping

Exclude files that do not need testing to keep the report focused on code that actually affects behavior. A badge gives a quick visual signal, but it is only as honest as the coverage configuration behind it:

SimpleCov.start do
  add_filter '/app/jobs/application_job.rb'
  add_filter '/app/mailers/application_mailer.rb'
  add_filter '/config/'
  
  # Group related files
  add_group 'Models', 'app/models/'
  add_group 'Controllers', 'app/controllers/'
  add_group 'Services', 'app/services/'
end

Grouping files by role makes the report easier to scan. Instead of one long list sorted by percentage, you see at a glance whether models, controllers, or services need attention. This is especially helpful in larger codebases where coverage varies widely by layer.

Coverage thresholds

Grouping files by their role in the application makes the report easier to scan. Instead of one long list sorted by percentage, you see at a glance whether models, controllers, or services need attention. This is especially helpful in larger codebases where coverage varies widely by layer.

Fail builds when coverage drops below a configured minimum, stopping pull requests from merging when they reduce coverage:

SimpleCov.start do
  minimum_coverage 80
  # or per-group thresholds
  minimum_coverage_by_group(
    models: 90,
    services: 80
  )
end

This stops PRs with regressing coverage from merging.

Coverage thresholds are best used as a guardrail, not a finish line. They can stop accidental regressions, but they should not push you into testing implementation details just to make the number go up.

Common Pitfalls

Testing generated code

Exclude scaffolds and migrations:

add_filter '/app/controllers/application_controller.rb'
add_filter '/db/migrate/'

Coverage Only at 100%

Don’t chase 100%. Focus on meaningful coverage of business logic, not boilerplate.

That advice is especially important in Rails apps, where a lot of files exist to support the framework itself. Coverage is still useful there, but the real value comes from exercising code that changes your business behavior.

Forgetting to start SimpleCov first

Always put SimpleCov.start at the very top of test helper. Code loaded before it won’t be tracked.

Summary

SimpleCov integrates smoothly with Ruby’s test frameworks. Run it locally to find untested code, add it to CI to track coverage over time, and use coverage data strategically, not as a vanity metric.

Start with reasonable expectations (70-80%), focus on business-critical code, and review what your tests actually assert. Coverage is a guide, not a goal.

If you want to see coverage data in a broader test workflow, continue with Integration Testing in Ruby. Integration tests show how coverage and behavior work together in a larger suite.

See Also