RSpec's aggregate_failures: Cleaner Multi-Assertion Tests Without the Boilerplate
RSpec's aggregate_failures lets multiple expectations run to completion and reports all failures together, eliminating the fix-one-re-run cycle for related assertions. Here's how to use it, a concrete controller spec example, and when to avoid over-aggregation.
23 May 2026, 16:59 UTC

The problem: one failure hides the rest
You’re testing a user registration endpoint. After a successful POST, you want to verify the response status, the returned JSON structure, and that a welcome email was queued. In a vanilla RSpec example, the first failed expectation aborts the example immediately. You fix that one, re-run, and discover the next failure. This cycle wastes time and clutters the test output with repeated setup.
RSpec 3.3 introduced aggregate_failures to solve exactly this: it evaluates every expectation inside a block, collects all failures, and reports them together in a single aggregated exception.
How it works
Two equivalent forms exist:
- Block form:
aggregate_failures { ... }wraps any number of expectations. - Metadata form:
it 'does something', :aggregate_failures do ... endapplies to the entire example.
Both cause RSpec to catch RSpec::Expectations::ExpectationNotMetError exceptions, store them, and continue executing the block. At the end, if any failures were collected, they’re raised together as a single RSpec::Expectations::MultipleExceptionError with each failure’s message and backtrace preserved.
Non-expectation exceptions (e.g., NoMethodError, network timeouts) are not aggregated—they abort the block immediately. This is intentional: a runtime error usually indicates a test setup problem, not a failed assertion.
Worked example: verifying a registration response
Consider a spec for a Rails controller action. Without aggregation, you’d either split into multiple it blocks (repeating the request) or accept that only the first failure surfaces.
# spec/controllers/registrations_controller_spec.rb
RSpec.describe RegistrationsController, type: :controller do
describe 'POST #create' do
let(:valid_params) { { user: { email: '[contact removed]', password: 'secret123' } } }
it 'returns a successful response with user data and queues welcome email', :aggregate_failures do
post :create, params: valid_params
expect(response).to have_http_status(:created)
expect(JSON.parse(response.body)).to include(
'id' => be_present,
'email' => '[contact removed]',
'token' => be_present
)
expect(ActionMailer::Base.deliveries.last).to have_attributes(
to: ['[contact removed]'],
subject: 'Welcome!'
)
end
end
end
Run this with bundle exec rspec spec/controllers/registrations_controller_spec.rb. If the status is 200 instead of 201, the JSON lacks a token, and the email wasn’t queued, you’ll see all three failures in one run:
Failures:
1) RegistrationsController POST #create returns a successful response with user data and queues welcome email
Got 3 failures from failure aggregation block:
1.1) expected the response to have status code :created (201) but was 200
1.2) expected {"id"=>1, "email"=>"[contact removed]"} to include {"token"=>(be present)}
1.3) expected nil to have attributes {:to=>["[contact removed]"], :subject=>"Welcome!"}
Each failure retains its own backtrace pointing to the exact expectation line. Fix one, re-run, and the remaining two are still reported.
Trade-offs and when to avoid it
aggregate_failures reduces boilerplate, but it can mask a design smell: an example that verifies too many unrelated behaviors. The single-responsibility principle for tests still applies. If the three assertions above belonged to different logical units (e.g., response format, email side-effect, database state), they’re better kept as separate examples with shared setup via before hooks or let.
Also remember that the example still passes or fails as a whole—CI will show one red test, not three. Formatters that count examples won’t reflect the multiple assertions.
Finally, if your block raises a plain Ruby exception (say, a typo in a variable name), aggregation stops and you only see that error. This is correct behavior but can surprise newcomers who expect all errors to be collected.
Try it today
Pick a spec file where you’ve split related assertions across multiple it blocks solely to see all failures. Wrap the assertions in aggregate_failures { ... } (or add :aggregate_failures metadata) and run the suite. Compare the output: you should see every expectation failure listed together, each with its own backtrace. If the example feels bloated afterward, that’s a signal to refactor the test design—not the feature.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.