The validations of your ActiveRecord models look very convenient, because they are easy to use and have a pretty DSL. Use them is the “rails way” so you think that is what you must use. But…
When you start an application this validations look pretty harmless and
you can think that even make tests for this validations is a waste of
time. But with the time and more features one or two of the models of
the application (maybe the User
model) begins to grow and is no more
that “simple” to understand what is happening.
At that moment you start to see code like:
user.save(validate: false)
class User < ActiveRecord::Base
validates_format_of :password, with: password_format, on: [:registration]
end
This happen because some models are used in different contexts. For
example in Rides, the model Trip
can be
created, published, edited (but not always), also can have seat requests,
and a lot of other possibilities. Maybe your application have this kind
of models that are used in a lot of places.
Now writing tests is not an option. The problem is that at this moment start to make tests is not that easy. There is a lot of coupling, the test will run slow and maybe the business rules that are implemented are not that clear.
If this is happening to you, you are not alone, even Discourse that is awesome have this problems.
class Topic < ActiveRecord::Base
validates :title, :presence => true,
:topic_title_length => true,
:quality_title => { :unless => :private_message? },
:unique_among => { :unless => Proc.new { |t| (SiteSetting.allow_duplicate_topic_titles? || t.private_message?) },
:message => :has_already_been_used,
:allow_blank => true,
:case_sensitive => false,
:collection => Proc.new{ Topic.listable_topics } }
validates :category_id,
:presence => true,
:exclusion => {
:in => Proc.new{[SiteSetting.uncategorized_category_id]}
},
:if => Proc.new { |t|
(t.new_record? || t.category_id_changed?) &&
!SiteSetting.allow_uncategorized_topics &&
(t.archetype.nil? || t.archetype == Archetype.default) &&
(!t.user_id || !t.user.staff?)
}
end
But although this is common. I think is bad to have no idea what your code is doing.
Imagine that you are working in an application to promote events. More specifically in the registration of events. And you have to validate the event has a name and that its date is in the future.
You will have a form with a field for the event name and other field
for the start date. If you are using simple_form
maybe it looks
something like this:
<%= simple_form_for @event do |f| %>
<%= f.input :name %>
<%= f.input :starts_at %>
<%= f.button :submit %>
<% end %>
And if you are using the rails conventions maybe the controller looks like this:
class EventsController < ApplicationController
def new
@event = Event.new
end
def create
@event = Event.new(event_params)
if @event.save
redirect_to event_path(@event)
else
render :new
end
end
end
Now we will introduce the custom validator that will have the responsibility of identify the errors in the event and then send that errors to the model to be displayed in the view.
class EventsController < ApplicationController
def new
@event = Event.new
end
def create
@event = Event.new(event_params)
errors = EventRegistrationValidator.errors_for(@event)
if errors.empty?
@event.save
redirect_to event_path(@event)
else
@event.add_errors(errors)
render :new
end
end
end
Now we need to implement the method Event#add_errors
class Event < ActiveRecord::Base
def add_errors(errors)
errors.each { |error| self.errors.add(*error) }
end
end
So Event#add_errors
is waiting for a collection of errors with the
form:
[[:name, :blank], [:starts_at, :must_be_in_the_future]]
This is an array of arrays, where each element are the parameters that
describe an error in an ActiveRecord
object. If we analyze this method
in detail, what we are doing is.
event.errors.add(:name, :blank)
event.errors.add(:starts_at, :must_be_in_the_future)
The best part of this is that we can still use the default Rails translations
using I18n
To implement the data structure that our validator needs to return first we will define the behavior with some tests.
describe 'Event registration validator' do
describe 'validates the presence of name' do
example do
event = event_with(name: 'Benito')
expect(errors_for(event, :name)).to be_empty
end
example do
event = event_with(name: '')
expect(errors_for(event, :name)).to eq [[:name, :blank]]
end
end
describe 'validates it starts in the future' do
example do
event = event_with(starts_at: '')
expect(errors_for(event, :starts_at)).to eq [[:starts_at, :blank]]
end
example do
event = event_with(starts_at: 2.days.from_now)
expect(errors_for(event, :starts_at)).to be_empty
end
example do
event = event_with(starts_at: 1.second.ago)
expect(errors_for(event, :starts_at))
.to eq [[:starts_at, :must_be_in_the_future]]
end
end
def event_with(attributes)
TestEventRecord.new(attributes)
end
def errors_for(event, attribute)
errors = EventRegistrationValidator.errors_for(event)
errors.select { |error_attribute, _| error_attribute == attribute }
end
class TestEventRecord
attr_reader :name, :starts
def inititalize(name: nil, starts_at: nil)
@name = name
@starts_at = starts_at
end
end
end
Here you can see how instead of use the Event
active record object, I
used a little “mock” that in this case has the same behavior. Even
thought the use this “mock” is no necessary, it can make you tests run
in milliseconds instead of seconds.
Also you can see that the most important part is to define the expected behavior in a clear way. Because the implementation can be made in a lot of different ways. One possible implementation could be:
class EventRegistrationValidator
def inititalize(event)
@event = event
end
def self.errors_for(event)
new(event).errors
end
def errors
[name_is_blank, is_not_in_the_future].compact
end
private
attr_reader :event
def name_is_blank
[:name, :blank] if event.name.blank?
end
def is_not_in_the_future
return [:starts_at, :blank] if event.starts_at.blank?
if event.starts_at < Time.current
[:starts_at, :must_be_in_the_future]
end
end
end
I like this implementation because I think that is clear and descriptive, but maybe you can do it better!
If we compare this example with a more traditional “Rails way” implementation. Maybe the “Rails way” implementation will be more easy, but I am sure it will be more complex
One advantage of this validators is that are very disposable and can be easily switched. Also you can use a different validator for each different context.
Also if you use this validators you will reduce the responsibility of your models using smaller objects with one responsibility.
¡And your tests will run much faster!
Here I try to share knowledge and fixes to common problems and struggles for ruby on rails developers, like How to fetch the latest-N-of-each record or How to test that an specific mail was sent or a Capybara cheatsheet. You can see more examples on Most recent posts or all posts.