One of the first unit test failures I encountered on a Rails 8.0 upgrade was:
can't cast RSpec::Mocks::Double
The error happens on saving an ActiveRecord object to the database.
Turns out, this is related to stricter validation in Active Record, that refuses to save a mock object.
Generally speaking, you should be using FactoryBot methods to create real ActiveRecord objects from your unit tests. And we were. So it puzzled me why we would get this error, as it did not seem like we were storing anything that was a mock.
ChatGPT got pretty confused as well -- It got confused as the exception was thrown from an after_commit
hook and its assumption was that there were attributes already set in the model that are being re-serialized and this was causing the issue.
We went through a listing of all the attributes of the record, examining their type (class) and none of them was a mock.
This was the point when I gave up on Gen AI and took another look at the model.
I quickly eliminated that this has anything to do with the connected
attribute that we are checking, by updating the test to save without the check. It didn't help, so I knew that the update itself was throwing.
Then I wondered if updating any column would throw the same error.
I updated a text column, and it worked - progress. Now I knew this might have something to do with either:
1. the specific column - maybe it has some validation that is behaving weird
2. the column type - maybe datetime columns behave differently with updates
Luckily there were a few other datetime columns, and when I updated any one of them with Time.current
, they threw the same error. We were getting somewhere.
On a hunch, that this was to do with Time.current
, I took a look at the unit test, to see if Time was being mocked - normally, we use Timecop as the best practice, but it was always possible that an engineer decided to be "creative".
This is what I found on the unit test:
let(:data_sync_at) { Time.current.to_i }
before do
allow(Time).to receive_message_chain(:current, :to_i).and_return(data_sync_at)
end
So what this does is stub the intermediate Time.current
call, and it is not that obvious! So when we used Time.current
to update the record, it tried to use a Mock object and failed.A simple Timecop.travel(data_sync_at)
fixed the issue.
Here is the Claude transcript, which happened later, when I was better able to guide the conversation along a more productive route!
Debugging Rails RSpec Error: RSpec::Mocks::Double Casting Issue
def update_last_connected_at update!(last_connected_at: Time.current) if connected? end
connected?
is returning an RSpec mock double instead of a boolean value, and Rails is trying to cast it to a boolean in the if
conditional.
Common causes:
- Unmocked association —
connected?
might depend on an association that's mocked but not configured properly - Missing stub — The
connected?
method itself might be mocked but not given a return value
Solutions:
1. Stub the method explicitly with a boolean:
allow(model).to receive(:connected?).and_return(true)
2. Build a proper test object instead of relying on mocks:
let(:model) { build(:your_model, connected: true) }
connected?
, the problem is likely that update!
itself is being called on a mock.
Most common cause: You're probably testing this method on a mocked or partially mocked model instance. When you stub the model without fully setting it up, calling update!
on it returns a double instead of actually updating the record.
Solutions:
1. Use create
or build
instead of double
:
let(:model) { create(:your_model) } model.update_last_connected_at
2. If you must use a double, stub update!
:
let(:model) { double(:your_model) } allow(model).to receive(:update!).and_return(true)
update!
is being called but something in the conditional logic is going wrong.
Most likely causes:
connected?
method doesn't exist or returns a mock- The model instance itself is getting wrapped in a double
- Database transaction/rollback issues
Debug steps: Add some debugging to see what's actually happening:
def update_last_connected_at puts "connected? returns: #{connected?.inspect}" puts "connected?.class: #{connected?.class}" update!(last_connected_at: Time.current) if connected? end
let(:data_sync_at) { Time.current.to_i } allow(Time).to receive_message_chain(:current, :to_i).and_return(data_sync_at) // call method that ends up saving the record # Model: after_commit :update_last_connected_at def update_last_connected_at update!(last_connected_at: Time.current) end
update!
is called inupdate_last_connected_at
update!
triggersafter_commit
after_commit
callsupdate_last_connected_at
again- Which calls
update!
again... and so on
This creates a recursive callback that RSpec is having trouble with, and the mock you set up on Time.current
might be interfering.
The real problem: You're missing the connected?
check that should prevent this recursion:
def update_last_connected_at update!(last_connected_at: Time.current) if connected? end
Or use update_column
instead (skips callbacks):
def update_last_connected_at update_column(:last_connected_at, Time.current) if connected? end
after_commit :update_last_connected_at, if: :saved_change_to_connected?
The problem: When update_last_connected_at
calls update!
, it triggers another after_commit
. Even though the guard if: :saved_change_to_connected?
prevents it from running again, RSpec is still having issues.
The real culprit is likely your Time mock:
allow(Time).to receive_message_chain(:current, :to_i).and_return(data_sync_at)
This is mocking Time.current
in a way that might be interfering with the after_commit
callback execution in your test. When you call update!(last_connected_at: Time.current)
inside the callback, the mocked Time.current
might be returning a double.
Try this instead:
Option 1: Mock just the .to_i part, not the whole chain:
allow_any_instance_of(Time).to receive(:to_i).and_return(data_sync_at)
Option 2: Use travel_to:
travel_to(Time.at(data_sync_at)) do # your test code end
Option 3: Simplify by not mocking Time at all:
let(:data_sync_at) { Time.current.to_i } # Just use the actual Time.current in your model
No comments:
Post a Comment