Las validaciones de tus modelos de ActiveRecord
parecieran muy
convenientes, porque son muy fáciles de usar, tiene un DSL muy
entendible y rails te las pone ahí a la mano para que las uses. Pero
esto no dura para siempre.
Cuando empiezas una aplicación estas validaciones parecen inofensivas y
hasta parece ridículo hacer pruebas para ellas. Después pasa que uno o
dos de los modelos de tu aplicación (muy probablemente uno de ellos sea
User
) empieza a crecer y deja de ser “tan simple”, la clase ya tiene
más de 300 lineas de código y el record es modificado de muchas formas
diferentes, en diferentes controladores.
Cuando estos problemas empiezan a llegar a tu aplicación, comenzaras a ver cosas como:
user.save(validate: false)
class User < ActiveRecord::Base
validates_format_of :password, with: password_format, on: [:registration]
end
Esto pasa porque algunos modelos son usados en diferentes contextos. Por
ejemplo en Rides, el modelo Trip
se puede
crear, plublicar, editar (pero no siempre), se pueden apartar lugares,
entre otras muchas otras posibilidades. Probablemente en tu aplicación
pasa lo mismo con algunos modelos.
En estos momentos ya no parece tan ridículo hacer pruebas (¿o sí?). El problema es que ahora es hacer pruebas no es tan fácil, hay mucho acoplamiento, las pruebas se vuelven lentas y algunas veces ya no estas seguro de las reglas que en un momento quisieron ser implementadas.
Este problema es común, como ejemplo podemos ver el modelo Topic en Discourse
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
El problema es cuando crees que esto esta bien y que es normal no tener idea que esta pasando.
La base para que funcionen este tipo de validaciones en una aplicación
de rails sigue siendo ActiveModel
ya que aprovecharemos el método
ActiveModel::Errors#add
.
Bueno ya hablamos mucho de el problema, continuemos con la solución. Imagina que estas trabajando en una aplicación para promover eventos, específicamente en la parte de registro de eventos. Y tienes que validar que el evento tenga un nombre, que tenga una fecha, y que la fecha no haya pasado.
Probablemente en la forma tienes un campo para el nombre del evento y
otro para la fecha. Si usaras simple_form
se vería como:
<%= simple_form_for @event do |f| %>
<%= f.input :name %>
<%= f.input :starts_at %>
<% end %>
Y si nos apegáramos lo más posible a un controlador de scaffold, es muy probable que tengas algo como:
class EventsController < ApplicationController
def new
@event = Event.new
end
def create
@event = Event.new(params[:event])
if @event.save
redirect_to event_path(@event)
else
render :new
end
end
end
Bueno en lugar de delegar la responsabilidad al modelo, usaremos un objeto que se encargará de saber cuales son los errores y luego se los pasará al modelo para que los pueda mostrar en la vista.
class EventsController < ApplicationController
def new
@event = Event.new
end
def create
@event = Event.new(params[:event])
errors = EventValidator.errors_for(@event)
if errors.empty?
@event.save
redirect_to event_path(@event)
else
@event.add_errors(errors)
render :new
end
end
end
Para lograr esto necesitamos implementar el método Event#add_errors
que sería algo así:
class Event < ActiveRecord::Base
def add_errors(errors)
errors.each { |error| self.errors.add(*error) }
end
end
Este método Event#add_errors
espera que los errores que recibe como
parámetros tengan la siguiente forma:
[[:name, :blank], [:starts_at, :must_be_in_the_future]]
Es decir un arreglo de arreglos, donde cada elemento son los parámetros
que describen un error en un objeto de ActiveRecord
. Si analizamos el
método más a detalle, realmente estamos haciendo:
event.errors.add(:name, :blank)
event.errors.add(:starts_at, :must_be_in_the_future)
Con esto podemos aprovechar que rails ya sabe traducir errores de este
tipo a mensajes “mas humanos” usando I18n
.
Para implementar la estructura de datos que necesita regresar nuestro validador empezaremos por definir el comportamiento con unas pruebas:
describe 'Event 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 = EventValidator.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
Nota como en lugar de usar directamente un objeto Event
que es un
active record object, use un pequeño “mock” que en este caso tiene el
mismo comportamiento. Aunque esto no es necesario, puede hacer que tus
pruebas se tarden milisegundos, en lugar de segundos.
Si ves lo más importante es definir el comportamiento deseado, y que este sea claro, ya que la implementación puede hacerse de muchas formas. Una posible solución es la siguiente.
class EventValidator
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
Esta es una implementación que a mi gusto es descriptiva pero tal vez tu lo puedes hacer mejor =).
Comparando este ejemplo con una posible implementación usando las
validaciones de ActiveRecord
, probablemente el ejemplo de las
validaciones sea más fácil, pero definitivamente más
complejo.
Una ventaja de utilizar este tipo de validaciones, es que son fácilmente desechables e intercambiables. Aparte que puedes hacer un validador para cada contexto que necesites.
Otra ventaja es que disminuyes la responsabilidad que hay en tus modelos y la distribuyes en objetos que tienen una sola responsabilidad.
¡Aparte las pruebas corren mucho más rápido!
I send an email each week, trying 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 post by topic.