An Awesome Rails Validations Trick
So here's an awesome little Rails validation trick.
You are obviously familiar with the standard Rails validation usage.
class Post < ActiveRecord::Base
validates :name, :body, presence: true
end
I'm sure you also know that validations can take an on
option to specify that they should run only on create or only on update.
class Post < ActiveRecord::Base
validates :name, :body, presence: true, on: :create
end
But here's the cool thing: you can actually specify a custom-context for on
:
class Post < ActiveRecord::Base
validates :name, :body, presence: true, on: :foo
end
And those validations only run when triggered explicitly: @post.valid?(:foo)
So Why Is This Cool?
So why is this cool? Because it will give you speedy tests on slow validations, such as a validation that depends on a 3rd-party API.
A classic example of this is a Card model for user's credit cards. In this hypothetical app, we are using Authorize.net CIM for card processing. When a user adds a card, a custom validation sends it off to Authorize.net to be validated and stored there. Within our own database, we only commit the last four letters of the card in order to stay (roughly) PCI-compliant.
class Card < ActiveRecord::Base
# (this in obviously bare-bones code)
# Associations
belongs_to :user
# Validations
validates :type, :number, :month, :year, :cvv, presence: true
validate :validate_credit_card
# Callbacks
before_create :trim_number
# Methods
def validate_credit_card
credit_card.validate
credit_card.errors.each { |key, array| array.each { |value| errors.add(key.to_sym, value) } }
end
def trim_number
self.number = number[-4..-1]
end
private
def credit_card
@credit_card ||= ActiveMerchant::Billing::CreditCard.new(
:number => number,
:month => month,
:year => year,
:verification_value => cvv,
:brand => brand
)
end
end
This works great... until we realize that our tests are crawling. Every test that needs to create a card resource is triggering the API call to Authorize.net.
The typical way out of this problem is to either stub the validate_credit_card
repeatedly in our tests, or to create a separate service object to handle the Authorize.net API card creation.
Using the trick above though, we can solve this easily.
First, we add a custom context to the validation.
class Card < ActiveRecord::Base
# ... other code omitted ...
validate :validate_credit_card, on: :card_check
# ... other code omitted ...
end
In our controller, we run the validation explicitly:
def create
@card = current_user.cards.new(card_params)
if @card.valid?(:card_check) and @card.save
flash[:success] = 'Card created.'
redirect_to @card.user
else
flash[:error] = 'Error occurred, see below.'
render :new
end
end
Our test suite, on the other hand, isn't triggering that Authorize.net API call on every card creation, because @card.valid?(:card_check)
is not being run.
Kudos
Kudos to @dhh who used this trick in one of his recent code gists.
We'd love to meet you.