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')
endSeems 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_testAdd pry & rspec-rails gems
In the Gemfile, modify the :development, :test group to be:
group :development, :test do
gem 'pry'
gem 'rspec-rails'
endrspec-railsis there as it’s my preferred Ruby on Rails test framework.pryis 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…)
$ bundleInstall 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.rbThis 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 CreateOrdersand 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
endRun 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.rbNow 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 statusLet’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'
endWhat 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')
endCode: 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
endThis 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)
endThis 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')
endThis 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
endRunning 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 statusWhat’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)
endCool, 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')
endI 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 failuresOh, 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')
endCode: 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
endI 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 failuresGreat, 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: 'from@domain.com', to: 'to@domain.com', subject: 'System Error', body: "Error with Save: \n Details: #{details}.").deliverAlready 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')
endThat 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')
endThat 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
endStrange 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.