Stop RSpec from Hiding Failures: Use aggregate_failures Intentionally
RSpec stops at the first failing expectation by default. aggregate_failures lets you collect multiple related expectation failures per example without aborting early.
25 Aug 2026, 16:24 UTC

The first failing expectation in an RSpec example hides the rest. You fix it, re-run, hit the next failure, fix that, and re-run again. For a group of related assertions about the same behavior, that iterative loop wastes time and obscures how broken the change actually is.
The practical decision is to keep the example atomic but report multiple expectation failures together. RSpec provides aggregate_failures for this purpose. It wraps a block, collects expectation failures instead of raising on the first one, and reports them all when the block exits. The example still fails once, and the run continues to the next test.
Fail fast is the default for a reason
RSpec examples stop at the first raised expectation error. This "fail fast" behavior protects you from cascading failures when later assertions depend on state established by earlier ones. However, the cost appears when assertions are independent checks of the same invariant, such as normalizing input, persisting state, and enqueuing a side effect.
In those cases, you want a full picture of the failure, not just the first symptom. aggregate_failures is an opt-in scope for that comprehensive view.
How aggregate_failures changes reporting
aggregate_failures is a method available in RSpec 3.3+. It expects a block and runs expectations inside it without immediately aborting. Failures are collected and re-raised as a compound failure at the block's exit.
Behavior is version sensitive. RSpec 3.x changed implementation details around failure aggregation and interaction with configuration such as config.expect_with :rspec. Before relying on this feature, verify your installed major version:
# Run in project root; no special permissions required
rspec --version
Additionally, review your Gemfile.lock for rspec-core and rspec-expectations versions to confirm compatibility assumptions.
Important limitation: aggregate_failures collects expectation failures, not arbitrary exceptions. If code inside the block raises a RuntimeError or another standard exception, the block will still abort immediately. Use it for assertions, not for guarding risky production code that may throw exceptions.
Worked example: registration invariants
Consider a user registration flow where several properties should hold together: email normalization, password hashing, and welcome email enqueue. These are related but independent checks of a single action.
# spec/models/user_spec.rb
require 'rails_helper'
RSpec.describe User, type: :model do
describe '#register' do
it 'establishes registration invariants' do
user = User.register(email: ' [contact removed] ', password: 'secret')
aggregate_failures 'registration invariants' do
expect(user.email).to eq('[contact removed]')
expect(user.password_digest).not_to be_nil
expect { user }.to have_enqueued_job(WelcomeMailerJob)
end
end
end
end
The block groups three expectations under a named label. If more than one fails, RSpec reports them together for that one example instead of stopping at the first mismatch. A practical way to verify this effect is to create a minimal spec with two deliberately failing expectations—once outside and once inside aggregate_failures—and compare the reported failure count per example.
Trade-off: signal vs noise
Aggregating failures increases signal when assertions are cohesive. It adds noise when they are unrelated. A large aggregate_failures block that mixes validation, persistence, and unrelated UI checks makes failures harder to triage.
Ordering dependencies are another risk. If expectations mutate shared state, later assertions may fail only because earlier ones ran. Aggregating can mask that ordering, making failures look independent when they are actually sequential.
To maintain a clean test suite, keep blocks small and named, and only aggregate expectations that describe the same conceptual invariant.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.