
šµ When ActiveRecord::Relation Fails You (and Your Test Isn't Broken... Until It Is)
By Derek Neighbors on April 4, 2025
How seemingly identical queries cause non-deterministic test failures in Rails
You write solid, unmocked tests with hand-crafted records. Your controller assigns them, your test compares themāand then⦠failure. Identical outputs, yet RSpec disagrees.
expected: #<ActiveRecord::Relation [#<Section id: 1...>, #<Section id: 2...>]>
got: #<ActiveRecord::Relation [#<Section id: 1...>, #<Section id: 2...>]>
(compared using ==)
*Note: The printed output looks identical, yet the test fails.*
Theyāre the same.
They print the same.
You paste them into your terminalāstill the same.
But RSpec says: āNope.ā
And hereās the worst part:
Sometimes the test passes. Sometimes it fails.
š² The Hidden Risk: Non-Deterministic Test Failures
This isnāt just a head-scratcher.
This is a test suite destabilizer.
Tests like this pass on Monday, fail on Tuesday, and gaslight your team all week.
Letās unpack why this happens.
š§ Whatās Really Going On?
ActiveRecord Relation
is a lazy query object. It doesnāt run SQL until you ask for the records.
That means:
Section.where(active: true)
ā¦is not an array of sections.
Itās a promise to eventually become an array of sections.
So when you do:
expect(assigns(:sections)).to eq(Section.where(active: true))
Youāre not comparing āthese two arrays contain the same thingsā ā youāre comparing:
assigns(:sections).records == Section.where(active: true).records
And that comparison is fragile.
Hereās what can break it:
- Record ordering
Relations without.order(...)
may return rows in a different order each time, depending on how the DB feels that day. - Load state
One Relation might have already executed its SQL (loaded into memory as records), while the other remains a lazy query, causing their internal states to differ. - Subtle query differences
Filtering byid
vs filtering byactive: true
might yield the same records but with different SQL and internal cache state.
In short:
If your test involves comparing two
Relation
objectsāeven when they look the sameāyouāre in probabilistic testing territory.
And probabilistic testing? Thatās just failure waiting in CI.
š„ A Concrete Example
# Controller
def index
@sections = Section.where(active: true)
end
# Spec
it "assigns active sections" do
section1 = Section.create!(title: "Algebra", active: true)
section2 = Section.create!(title: "Biology", active: true)
expected = Section.where(id: [section1.id, section2.id])
get :index
expect(assigns(:sections)).to eq(expected)
end
This might pass.
Then fail the next run.
Then pass again.
Why?
Because the database might return rows in a different order. One run might give [section1, section2]
, another [section2, section1]
. Since Relation == Relation
delegates to Array == Array
āwhich is order-sensitiveāthe comparison fails when orders mismatch.
[1, 2] == [2, 1] # => false
ā The Fix: Compare What You Actually Mean
āļø Option 1: Compare record IDs
expect(assigns(:sections).map(&:id).sort)
.to eq([section1.id, section2.id].sort)
This removes ActiveRecordās internal state from the equation.
āļø Option 2: Use match_array
to ignore order
expect(assigns(:sections)).to match_array([section1, section2])
This says: āI care about what was returned, not how.ā
āļø Option 3: Convert relations to plain old arrays
expect(assigns(:sections).to_a).to eq(expected.to_a)
Forces both relations to load as arrays, reducing laziness issues. But itās still order-sensitiveāpair with `.sort` or `match_array` if order doesnāt matter.
This is more deterministic but still order-sensitiveāuse with care.
š§ Final Thought: Rails Is Powerful, But Rails Relations Are Lazy
Relations are promises, not values.
Theyāre not arrays.
They donāt behave like arrays.
And comparing them like arrays will betray you.
The failure isnāt that your test is wrong.
The failure is assuming Rails does just enough.
In reality, it sometimes does too much.
So when two Relation
objects look the same but fail equality, remember:
Your test didnāt breakāit was never safe to begin with.
⨠Pro Tip
If youāre building complex controller or view specs, wrap this logic:
def normalize_relation(rel, key = :id)
rel.map(&key).sort
end
Then use:
expect(normalize_relation(assigns(:sections)))
.to eq(normalize_relation(expected))
# Or for custom attributes:
expect(normalize_relation(assigns(:sections), :title))
.to eq(normalize_relation(expected, :title))
Consistency, clarity, and no more phantom failures at 3 AM.
Further Reading
- Effective Testing with RSpec 3 by Myron Marston & Ian Dees
- The Rails 5 Way by Obie Fernandez
- Practical Object-Oriented Design in Ruby by Sandi Metz
- Growing Rails Applications in Practice by Henning Koch & Thomas Eisenbarth
- Understanding the Four-Phase Test by Gerard Meszaros