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 filtersSimpleCov.start 'gem_name'; For gem developmentSimpleCov.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:
- Is this code dead? If so, remove it.
- Is this an edge case I should test? Add a test.
- 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:
[](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.
forward-link
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
- Minitest Basics; Setting up your first Ruby tests
- Mocking and Stubbing with Minitest; Testing dependencies
- Integration Testing in Ruby; Beyond unit tests
- CI Setup with GitHub Actions; Automating your test suite