Testing Rails Callbacks: A Step in the Right Direction

Anyone who’s worked with tightly-coupled models in rails will likely know the pain of tracing the stack for an error triggered from a callback method. Such painful experiences have led developers to come to a consensus on general rules like “Use a callback only when the logic refers to state internal to the object”, according to Jonathan Wallace. More on this topic by Samuel Mullen in a great blogpost, The Problem with Rails Callbacks.

The following post revolves around an Invitation model I wrote with my team mates and then later debated the callback to use.

The Invitation model generates a unique token which it uses to identify users who have clicked on a registration link.

The callback I chose to use first was after_initialize because an invite object must always have a token.

invitation.rb
1
2
3
4
5
6
7
class Invitation < ActiveRecord::Base
  after initialize :add_token #NOTE my syntax highlighter dislikes the underscore. see below.

  def add_unique_token
    self.token = Tokenizer.Generate
  end
end

NOTE after initialize above should be after_initialize. For some reason my syntax highlighting plugin doesn’t like the underscore in the correct version.

The associated test:

invitation_spec.rb
1
2
3
4
5
6
describe Invitation do
  it "generates a random token on initialize" do
    invite = Invitation.new
    expect(invite.token).not_to be_nil
  end
end

However, another team mate suggested using before_create instead.

His model and test:

invitation.rb
1
2
3
4
5
6
7
class Invitation < ActiveRecord::Base
  before_create :add_token

  def add_unique_token
    self.token = Tokenizer.generate
  end
end
invitation_spec.rb
1
2
3
4
5
6
7
8
describe Invitation do
  it "generates a random token before create" do
    invite = Invitation.new
    expect(invite.token).to be_nil
    invite.save
    expect(invite.token).not_to be_nil
  end
end

Why does this subtle change matter?

First, using after_initialize could produce a false positive. While the after_initialize test does exactly what the it statement says, there’s a chance that we may miss, say, a validation error that happens before save. This test would pass, and so would the validations, but for some reason you might find that your invitations aren’t working properly. after_initialize allows us to omit saving from the test, which one could generally assume is always desired given that callbacks should only “refer to state internal to an object”.

Additionally, if you’re testing a callback that affects the internal state of an object, we must test for a state-change. Using after_initialize, we can tell that a new invite has a token. However, we cannot say that we’ve tested the non-presence and the presence of the token. The pivotal point again is around #save. #save triggers the change and we can say with certainty that we’ve not only seen a “zero to one” change happen, but that the change has persisted in our database.

UPDATE: Twitter comment from @scottadhoc: “what happens if you load that record from the db into a new object and then assert on that field?”

Good point! If I understand your suggestion correctly, here’s how I might refactor the after_initialize version of the spec:

invitation_spec.rb
1
2
3
4
5
6
7
8
describe Invitation do
  it "generates a random token #after_initialize" do
    invite = Invitation.new
    expect(invite.token).not_to be_nil
    invite.save!
    expect(invite.token).not_to be_nil
  end
end

This way, we can make sure that the data persisted. While this works, my feeling is that the implication of testing after_initialize allows one to omit the persistence part. I certainly fell into that trap and wouldn’t be surprised if I forget again while testing an after_initialize callback.

Lastly, if you use after_initialize with the underscore in a block of code in Octopress, your blog will refuse to generate. Coincidence? I think not.

Comments