TDD In-Depth Rails Callback
This article started as experiments in Rails’ ActiveRecord’s Callbacks. I want to have a state-machine in part of our Rails app. The main application for this would be to know the object’s status.
There are gems out there that does exactly this and more (i.e. know which states to transition to and from)
I wanted to roll a quick thing as it’s just for an object’s status
(maybe famous last words?)
I had a problem resolving a test case that would mark the status
of
an object as failed
whenever the update failed.
The test case would be:
it 'any orders with save failures are marked :failed' do
order = Order.new
order.save
allow_any_instance_of(Order).to receive(:save).and_return(false)
order.update({ amount: 100.0 })
expect(order.reload.status).to eq('failed')
end
Seems pretty easy, right?
A simple test that is not so easy to make pass.
I found a way to the solution, and instead of just posting the solution, I will go through my thinking towards the solution.
Requirements
If you want to follow along:
Installing Vagrant and Virtualbox information can be found at:
I like to have a consistent environment that is working. The environment I will be using is from a Vagrant box. The set up files are here.
With the file downloaded, run: $ vagrant up
, which will create a
working environment. Connect to the environment using: $ vagrant
ssh
.
All of the commands and code will be working from within the vagrant environment.
Get Code
The source for this article is all here
If you want to work offline, say on a train traveling 60 miles / 100 miles per hour, definitely have this cloned and run vagrant up.
$ rails new callbacks_test
Add pry & rspec-rails gems
In the Gemfile, modify the :development, :test
group to be:
group :development, :test do
gem 'pry'
gem 'rspec-rails'
end
rspec-rails
is there as it’s my preferred Ruby on Rails test framework.pry
is there as it helps me debug but also drop into a certain state to understand specific parts of the system. (Even though using a debugger is a no-no…)
$ bundle
Install Rspec
After bundle has installed the pry and rspec gems, install rspec into the Rails app:
$ rails generate rspec:install
Running via Spring preloader in process 15566
create .rspec
create spec
create spec/spec_helper.rb
create spec/rails_helper.rb
This adds hooks when creating a migration in the next step.
Create Order Model
Now that we have a new Rails app with basic testing and debugging support, let’s make a useful model.
I’m going with an order model that has these attributes:
- amount
- received
- status
As this is to test out and learn part of the ActiveRecord system, I want to have a minimal object. Let’s create the model first using a migration:
$ rails generate migration CreateOrders
and edit the corresponding migration file so it looks like:
class CreateOrders < ActiveRecord::Migration[5.2]
def change
create_table :orders do |t|
t.string :name
t.float :amount
t.float :received
t.string :status
t.timestamps
end
end
end
Run the corresponding migration:
$ rake db:migrate
== 20190118225308 CreateOrders: migrating =====================================
-- create_table(:orders)
-> 0.0027s
== 20190118225308 CreateOrders: migrated (0.0028s) ============================
Ok, now we’re ready to… write our first test!
Set up Rspec for Order
Run the $ rails generate rspec:model <model name>
command to
generate the Rspec files for the order test.
$ rails generate rspec:model order
Running via Spring preloader in process 15638
create spec/models/order_spec.rb
Now we’re ready to write tests!
States
Before writing tests, let’s define the status fields and how it will change:
Condition | Status |
---|---|
created | :open |
when amount > 0 | :pending |
when received == amount | :received |
when save fails | :failed |
I want cases where the status changes based on different fields. In this case, I want to follow the workflow of a basic order processing system.
Test: Order is open
it 'new orders are created with :open status' do
order = Order.new
order.save
expect(order.status).to eq('open')
end
Running the tests:
vagrant@ubuntu-xenial:~/callbacks_test$ rspec
F
Failures:
1) Order new orders are created with :open status
Failure/Error: expect(order.status).to eq('open')
expected: "open"
got: nil
(compared using ==)
# ./spec/models/order_spec.rb:10:in `block (2 levels) in <top (required)>'
Finished in 0.01853 seconds (files took 1.05 seconds to load)
1 example, 1 failure
Failed examples:
rspec ./spec/models/order_spec.rb:5 # Order new orders are created with :open status
Let’s go with the created condition, where an order’s status
is to
be: open
.
Code: Order is :open
So, if I were to take the easiest path to make this pass: write a
function that directly modifies the status
value before the save…
I couldn’t think of any way of doing that, not without using ActiveRecord Callbacks.
Using callbacks are a way to perform modification during certain lifecycle events of an object. From the documentation:
- before_validation
- after_validation
- before_save
- around_save
- before_update
- around_update
- after_update
- after_save
- after_commit/after_rollback
To modify status
, use the before_save
callback, which requires a
method. modify_status
will be the method used for before_save
before_save :modify_status
private
def modify_status
self.status = 'open'
end
What happens here? Before an object is saved, Rails checks if there
are any lifecycle events. In this case, before_save
is there and
executes the modify_status
method.
Within the modify_status
method, the status is adjusted to: open
.
Any new Order objects created will have its status
automatically set
to: open
.
Test: Order is pending
For the next test, we want the order’s status
to be set to:
pending
if the amount is greater than 0.
The test for that would be:
it 'open orders that have an amount should become pending' do
order = Order.create
order.amount = 100.0
order.save
expect(order.status).to eq('pending')
end
Code: Order is pending
There’s two ways to make the test pass.
Straight-forward
Now, to make this test pass, the simplist way of doing it would be
to modify the modify_status
method to:
def modify_status
self.status = 'open'
self.status = 'pending' if amount && amount > 0
end
This would be a simple way to approach this.
Callback
Another way to pass the test: use ActiveRecord’s change methods. In
this case, it would be: attribute_changed?
def modify_status
self.status = 'open'
self.status = 'pending' if attribute_changed?(:amount)
end
This is slightly more complicated than the previous method, but I want to highlight ActiveRecord’s Dirty methods.
Which is better? In most cases, the former would be but it all depends. There are only two tests so far.
If these were the only two tests, the former would be better. As there are more tests coming, I won’t do a big refactor just yet.
Test: Order is received
Now let’s add a test for the received
case, where:
Order.amount == Order.received
The test would look like:
it 'existing orders have received == amount, its status is received' do
order = Order.create(:amount => 100)
order.received = 100
order.save
expect(order.status).to eq('received')
end
This time, I take advantage of the create
method and set the
amount
value on creation.
The received value is set afterwards, instead of with amount
, why?
The main reason: I want to have flexibility in how order is created. In this case, order is created directly, but from experience, I know I want to use another method or system to create orders.
Code: Order is received
Simple approach
The simple approach would be to add onto the modify_status
method
and implement code as described:
def modify_status
self.status = 'open'
self.status = 'pending' if attribute_changed?(:amount)
self.status = 'received' if amount == received
end
Running the tests:
vagrant@ubuntu-xenial:~/callbacks_test$ rspec
F..
Failures:
1) Order new orders are created with :open status
Failure/Error: expect(order.status).to eq('open')
expected: "open"
got: "received"
(compared using ==)
# ./spec/models/order_spec.rb:10:in `block (2 levels) in <top (required)>'
Finished in 0.03174 seconds (files took 1.24 seconds to load)
3 examples, 1 failure
Failed examples:
rspec ./spec/models/order_spec.rb:5 # Order new orders are created with :open status
What’s going on??
Hmm… the test that failed is not the newest test but the first test! Why??
Well, on order create, the amount
is nil and received
is
nil. So, they are equal!
Only after create
How can the code be written so this does not happen on create, but does happen for the pending case?
This situation is primarily caused by nil == nil
, so let’s see if we
can solve that with:
def modify_status
self.status = 'open'
self.status = 'pending' if attribute_changed?(:amount)
self.status = 'received' if !amount.nil? && (amount == received)
end
Cool, that was quick.
Test: Irrational values of amount and received
So I have experienced a case where not all values of amount ==
received
, even when the values are. The main reason, the values are
stored in an irrational way, so they can never be perfectly equal.
it 'also handles potential irrational numbers' do
order = Order.create(:amount => 0.33)
order.received = 0.33
order.save
expect(order.status).to eq('received')
end
I kind of expect this to fail… but let’s see:
vagrant@ubuntu-xenial:~/callbacks_test$ rspec
....
Finished in 0.04418 seconds (files took 1.03 seconds to load)
4 examples, 0 failures
Oh, I guess I was over thinking this case. So there’s no need for code to fix this test as it’s already passing.
Test: Update status to failed on save failure
One test I implement when there’s a database involved: what if the save fails?
I know databases are reliable, but who knows, what if the database runs out of space? How would errors be known?
I can write a test where the database is filled up with data, but that would put the system I am running on at risk, so I use a stub.
it 'any orders with save failures are marked :failed' do
order = Order.create
allow_any_instance_of(Order).to receive(:create_or_update).and_return(false)
order.amount = 100.00
order.save
expect(order.reload.status).to eq('failed')
end
Code: Update status to failed on save failure
Taking the same approach this time will not work. Why?
Looking at Rails’ save documentation, it mentions:
There’s a series of callbacks associated with save. If any of the before_* callbacks return false the action is cancelled and save returns false. See ActiveRecord::Callbacks for further details.
The callback system used so far before_save
will not be active in
the event of a save, the actions will be canceled!
So, what can be done?
What happens when a actions are halted?
The whole callback chain is wrapped in a transaction. If any callback raises an exception, the execution chain gets halted and a ROLLBACK is issued.
Aaaah, the keyword is rollback.
Looking at the lifecycle events for callbacks, I notice in the list:
- before_validation
- after_validation
- before_save
- around_save
- before_update
- around_update
- after_update
- after_save
- after_commit/ after_rollback
So, if a after_rollback
method was there, this would be called in
the case save
ever failed.
class Order < ApplicationRecord
before_save :modify_status
after_rollback :mark_failure
# ...
def mark_failure
self.update_column(:status, 'failed')
end
end
I use update_column
as it would provide another mechanism to update
the status than create_or_update
.
Let’s see if this works:
vagrant@ubuntu-xenial:~/callbacks_test$ rspec
.....
Finished in 0.04501 seconds (files took 1.05 seconds to load)
5 examples, 0 failures
Great, tests are all passing!
Question: Is this realistic?
In a way, this is not realistic. Why? This last test kind of simulates a database failure. Where, if the first time writing the data fails, why would writing with a different method pass???
Completely agree.
So, here is probably where good error logging service would come in handy. Something that does not use the same database to inform users something bad has happened.
Instead of writing the data, send an email!
Use Rails’ ActionMailer:
ActionMailer::Base.mail(from: '[email protected]', to: '[email protected]', subject: 'System Error', body: "Error with Save: \n Details: #{details}.").deliver
Already have too many emails? How about getting info on Slack? Here’s how to get messages in Slack
The key point: fatal errors are caught and there’s notification for them.
Note: don’t stub save
Originally, I wrote the test as:
it 'any orders with save failures are marked :failed' do
order = Order.new
order.save
allow_any_instance_of(Order).to receive(:save).and_return(false)
order.update({ amount: 100.0 })
expect(order.reload.status).to eq('failed')
end
That passes as is, but for the article, I updated to:
it 'any orders with save failures are marked :failed' do
order = Order.create
allow_any_instance_of(Order).to receive(:save).and_return(false)
order.amount = 100
order.save
expect(order.reload.status).to eq('failed')
end
That started failing. I remembered in Rails, to stub save
to fail,
do not stub save
, but stub: create_or_update
.
When looking at the source for save:
# File activerecord/lib/active_record/base.rb, line 2575
def save
create_or_update
end
Strange that stubbing save
fails, but create_or_update
passes. Guess there’s additional magic under the hood…
Conclusion
What started out as an exercise in state transitions using
ActiveRecord callbacks ended in diving into the guts of
ActiveRecord::Base class’ save
method to solve a simple problem:
How to mark an item when the database fails?
The solution presented is not perfect, but I feel it is pretty good and there’s opportunity for improvements. At the same time, if the database situation is so catastrophic, nothing except working with an independent notification that is external to system like email or Slack will help.