Red Green Repeat Adventures of a Spec Driven Junkie

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.

Vincent van Gogh - La Berceuse (Woman Rocking a Cradle)

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.

Source

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

source

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

source

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.