Testing for Change
How using the RSpec `change` matcher can make your Rails tests easier to read and maintain.
Writing tests that check that a value you don't control has changed is a weird thing.
To help out, RSpec provides a change matcher that can help make things easier.
Let's look at an example of how we can use the change matcher to create more robust tests for our Rails app.
The Problem: Tracking Changes to our Models
We have a User
model and want to make sure that anytime it's changed, a Trail
is created to record that change. Here are the models:
app/models/user.rb
# == Schema Information
#
# Table name: users
#
# id :bigint not null, primary key
# first_name :string not null
# last_name :string not null
class User < ApplicationRecord
has_many :trails, as: :origin
end
app/models/trail.rb
# == Schema Information
#
# Table name: trails
#
# id :bigint not null, primary key
# attr_changed :string not null
# changed_from :string
# changed_to :string
# origin_id :bigint not null
# origin_type :string not null
class Trail < ApplicationRecord
belongs_to :origin, polymorphic: true
end
Making Trails after update
Our first goal, is to make it so a Trail
record is created anytime a user's first name is changed.
spec/models/user_spec.rb
describe User do
it 'creates a trail when the first_name is changed' do
user = FactoryBot.create(:user)
user.update(first_name: 'Something New')
expect(user.trails.where(attr_changed: 'first_name').count ).to be(1)
end
end
One way to make only this test pass would be to add callback to our User
model:
app/models/user.rb
class User < ApplicationRecord
has_many :trails, as: :origin
after_update :create_trail
private
def create_trail
return unless first_name_previously_changed?
Trail.create(
origin: self,
attr_changed: 'first_name',
from: first_name_previously_was,
to: first_name,
end
end
This is great!
Our tests passes. It's easy to read. What could go wrong? If we change the requirements so that a trail is made when we create the User
then
things get a little funny.
Making Trails after create
After adding a new spec to make sure trails are made on create:
spec/models/user_spec.rb
it 'creates first_name trail when the user is created ' do
user = FactoryBot.build(:user)
user.save
expect(user.trails.count).to be(1)
end
We update our callback to run more generally:
app/models/user.rb
class User < ApplicationRecord
has_many :trails, as: :origin
- after_update :create_trail
+ after_save :create_trail
We'll see that our new spec passes (woo!) but our first spec fails (boo!).
Magic Numbers
The problem with our specs is they rely on magic numbers. We've made too hard of a rule: there must be precisely 1
trail for our user after we've changed it's first_name.
How do we fix this weirdness?
Option 1: More Setup
A little more setup could get us what we needed. By recording the previous count, we calculate the next count and match against that.
spec/models/user_spec.rb
describe User do
it 'creates first_name trail when the user is created ' do
previous_trails_count = 0
next_trails_count = previous_trails_count + 1
user = FactoryBot.create(:user)
expect(user.trails.where(attr_changed: 'first_name').count ).to be(next_trails_count)
end
it 'creates a trail when the first_name is changed' do
user = FactoryBot.create(:user)
previous_trails_count = user.trails.count
next_trails_count = previous_trails_count + 1
user.update(first_name: 'Something New')
expect(user.trails.where(attr_changed: 'first_name').count).to be(next_trails_count)
end
end
No more magic numbers!
Unfortunately it is a lot more to read and our specs are not as straight-forward as they once were.
Option 2: Expecting Change
RSpec's change
matcher is meant for situations just like these. When we pass a block to both the expect
and change
methods it we can hide all the setup we had to define explicitly in the examples above. Here's what those two tests would look like re-written with change
:
spec/models/user_spec.rb
describe User do
it 'creates first_name trail when the user is created ' do
user = FactoryBot.build(:user)
expect { user.save }.to(
change { user.trails.where(attr_changed: 'first_name').count }
)
end
it 'creates a trail when the first_name is changed' do
user = FactoryBot.create(:user)
expect { user.update(first_name: 'Something New') }.to(
change { user.trails.where(attr_changed: 'first_name').count }
)
end
end
How to read a change
expectation
These change
expectations are a bit confusing at first glance, so let's try to break it down.
Here is the basic structure
expect(block_that_does_something).to change(block_that_gets_value)
A rough re-write of this execution path might look like this:
previous_value = block_that_gets_value.call()
block_that_does_something.call()
next_value = block_that_gets_value.call()
expect(next_value).to_not be(previous_value)
Expecting more
There's more to the change
matcher then I've shown here. Auxiliary methods allow us to specify by how much the value should change, exactly what it should change too, and also assert that it did not change.
To learn more about these methods you can visit the RSpec Documentation