Stop Fixing One Broken Expectation at a Time: RSpec's aggregate_failures
RSpec's aggregate_failures collects every failed expectation in an example instead of stopping at the first. Here's how to enable it, where it pays off, and where it backfires.
26 Aug 2025, 04:51 UTC

You refactor a validation on a model, run the spec, and see one failure. You fix it, rerun, and a second failure appears. Then a third. Each run costs you the full feedback cycle, and the failures were all caused by the same change. RSpec has a built-in answer to this: aggregate_failures, which collects every failed expectation in an example and reports them together instead of stopping at the first one.
The thesis is simple: for examples that check several independent facts about one object or response, failing fast is a bug, not a feature. Aggregating failures turns a slow serial debugging session into a single informative run.
What changes when aggregation is on
By default, a failed expect(...) raises an exception that aborts the example immediately. Any expectations after it never execute. With aggregate_failures active, RSpec catches each failure, keeps running, and at the end of the example reports every mismatch it collected — each with its own message, file, and line number, so debuggability is preserved.
This works with the standard expect syntax and common matchers, including block matchers like change and raise_error. The feature has been stable since RSpec 3.0; you can confirm your version with rspec --version in your project directory.
Three ways to enable it
Per example or group — the most surgical option, and the one to reach for first:
RSpec.describe Invoice do
it "calculates totals correctly", :aggregate_failures do
invoice = described_class.new(lines: sample_lines)
expect(invoice.subtotal).to eq(100.00)
expect(invoice.tax).to eq(8.25)
expect(invoice.total).to eq(108.25)
expect(invoice.currency).to eq("USD")
end
endTagging with :aggregate_failures works because RSpec treats it as metadata that toggles the behavior for that example or group. You can also use the block form when you only want part of an example aggregated:
it "renders the response" do
aggregate_failures "response fields" do
expect(json["status"]).to eq("ok")
expect(json["items"].size).to eq(3)
end
endScoped globally — in spec_helper.rb (requires write access to your spec configuration; rerun the suite after editing):
RSpec.configure do |config|
config.define_derived_metadata(type: :request) do |meta|
meta[:aggregate_failures] = true
end
endApplying it through derived metadata is safer than a blanket global flag because you can scope it — here, only to request specs, where it pays off most.
A worked example: request specs, and how to verify it engaged
A single HTTP call produces many independent facts — status code, headers, body fields — and when an endpoint breaks, several are often wrong at once:
RSpec.describe "GET /api/v1/orders/:id", type: :request do
it "returns the order", :aggregate_failures do
order = create(:order, status: "shipped")
get "/api/v1/orders/#{order.id}"
body = JSON.parse(response.body)
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq("application/json")
expect(body["status"]).to eq("shipped")
expect(body["items"]).not_to be_empty
end
endWithout aggregation, a serializer regression shows up as one failure at a time across four separate runs. With it, one run tells you the status is fine but two body fields are wrong — which points straight at serialization rather than routing. Run it as usual with bundle exec rspec spec/requests/orders_spec.rb; no special flags are needed.
The practical check that aggregation actually engaged: temporarily add a second, deliberately wrong expectation after a failing one, run the spec, and confirm both failure messages appear in the output. If you only see the first, the tag isn't applied — common causes are a typo in the metadata symbol or editing a config file the spec doesn't load (e.g., rails_helper.rb vs spec_helper.rb). Then remove the deliberate failure.
Trade-offs and the takeaway
Aggregation is not free. First, it can mask ordering bugs: if expectation two only passes because expectation one ran first, failing fast would have surfaced the dependency; aggregation hides it. Keep aggregated expectations independent of each other.
Second, output volume. An example with thirty expectations can produce a wall of failure text that's harder to act on than one clean failure. If you routinely see more than a handful of aggregated failures, the example is probably doing too much and should be split.
Third, enabling it globally changes behavior for examples you didn't write with it in mind. Prefer per-example or per-type scoping until you've seen how your suite behaves.
Reach for :aggregate_failures on any example that asserts multiple independent facts about one thing — request specs, serializer specs, validation-heavy model specs. Start scoped, verify with a deliberate double failure, and split examples whose aggregated output becomes unreadable. Your future self, debugging a refactor at 5pm, will thank you for getting all the bad news in one run.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.