😵 When ActiveRecord::Relation Fails You (and Your Test Isn't Broken... Until It Is)

😵 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 by id vs filtering by active: 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