RSpec: One Expectation Per Example, or aggregate_failures? A Practical Decision Guide
Decide between one expectation per example, plain multiple expectations, and RSpec's :aggregate_failures based on setup cost and assertion independence — with a working request-spec example and a validation procedure.
14 Apr 2026, 21:51 UTC

You have a request spec that takes 300 ms to set up a user, a project, and an API token. Splitting every assertion into its own example multiplies that cost; cramming everything into one example means the first failure hides the rest. RSpec gives you three supported shapes for this, and the right choice depends on setup cost, independence of the checks, and how you read failures.
The decision and its constraints
The default convention in RSpec is one example per observable behavior. That keeps failure messages precise: the example description names what broke. The tension appears when setup is expensive (factories hitting the database, external service stubs, file fixtures) or when several assertions genuinely describe one behavior, such as "the response is well-formed." RSpec 3's :aggregate_failures metadata exists for exactly this case: it collects all expect failures inside one example and reports them together instead of aborting at the first failure.
Comparing the three options
| Approach | Setup cost | Failure signal | Main risk |
|---|---|---|---|
| Separate examples, one expectation each | Setup repeats per example (mitigated by let/before caching within an example only) | Clearest: one failure, one named behavior, fail-fast isolation | Slow suites when factories are heavy; duplicated descriptions drift apart |
| Multiple expectations, no aggregation | Setup runs once | First failure aborts the example; later expectations never run | You fix failure #1, rerun, and discover failure #2 — slow feedback loop |
Multiple expectations with :aggregate_failures | Setup runs once | All expectation failures listed in one run with individual messages | Can mask coupling; example description may stop naming a single behavior |
Note what aggregation does not do: it collects expectation failures only. A raised exception — a timeout, a NoMethodError, a database connection error — still aborts the example immediately, and any expectations after that point never execute.
Trade-offs that actually matter
Independence is the hard requirement. Only aggregate assertions whose later checks do not depend on earlier ones passing. If expectation two dereferences something expectation one asserted exists, a failure in one produces a confusing exception in two — aggregated or not. In that case, use separate examples or explicit control flow.
Aggregation fits request/API specs well. "The response has status 200, the JSON id matches, and the state field is 'ready'" is arguably one behavior — a well-formed response — and these checks are independent of each other. Model unit specs with cheap setup usually gain nothing from aggregation and lose the one-behavior-per-name discipline.
Watch the description. If your it string needs the word "and" twice, you are probably describing three behaviors and should split. Aggregation is a reporting tool, not permission to write grab-bag examples.
Concrete implementation
Metadata form, applied to a single example (RSpec 3; nested example groups inherit metadata in modern versions, but confirm against your installed gem):
# spec/requests/records_spec.rb — run with: bundle exec rspec spec/requests/records_spec.rb
RSpec.describe 'GET /records/:id', type: :request do
let!(:record) { create(:record, state: 'ready') }
it 'returns a well-formed response', :aggregate_failures do
get record_path(record), headers: auth_headers
expect(response).to have_http_status(:ok)
expect(parsed_body['id']).to eq(record.id)
expect(parsed_body['state']).to eq('ready')
end
endBlock form, when only one section of a larger example should collect failures:
it 'processes the order' do
order = described_class.call(cart)
aggregate_failures 'order results' do
expect(order).to be_persisted
expect(order.total_cents).to eq(2500)
end
expect(OrderMailer).to have_received(:confirmation).with(order)
endKeep shared setup in before/let minimal and deterministic; aggregation amplifies the damage from flaky setup because one bad setup fails several assertions at once.
Validating the behavior before you adopt it
First confirm your installed version, since metadata inheritance and failure output formatting vary within RSpec 3:
bundle exec ruby -e 'require "rspec/core"; puts RSpec::Core::Version::STRING'Run this from your app root with the bundle installed; it prints the exact RSpec version your specs execute under. Then verify aggregation actually works in your setup:
- Run the spec as written:
bundle exec rspec spec/requests/records_spec.rb— it should be green. - Temporarily break two of the three expectations (e.g., assert status
:createdand state'pending'). - Rerun. The failure output should list both failures in one example result, each with its own message. If you only see the first, the metadata did not apply — check for typos and confirm your
spec_helper.rbis not stripping metadata. - Restore the expectations and confirm green again. No production code changes, so no rollback needed beyond undoing the temporary edits.
Before adopting the pattern broadly, compare runtime and failure readability against a split-example variant of the same file. If the split version costs only a few hundred milliseconds, prefer it — isolation is worth more than marginal speed.
Limitations
- Aggregation does not rescue non-expectation exceptions; dependent assertions still belong in separate examples.
- Failure output from aggregated examples is longer; in CI logs it can be harder to scan than one-failure-per-example output.
- Exact metadata inheritance to nested groups and output formatting are version-sensitive within RSpec 3 — verify against the installed gem rather than assuming from documentation written for a different minor version.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.