A custom validator solves the problem of repeated custom validation logic. But is also allows to improve its testability.
The following is an example of testing a custom validator in isolation.
Model
The example is about validating a users email address format.
The User model:
rails g model User email:string
The email attribute has to correspond to a valid email format. The model benefits from a custom email validator:
# models/user.rb
require 'email_validator'
class User < ApplicationRecord
validates :email, email: true
end
Custom validator implementation
The custom email validator with a plausible regular expression for emails:
# lib/validators/email_validator.rb
class EmailValidator < ActiveModel::EachValidator
EMAIL_FORMAT = /\A([\w\.%\+\-]+)@([\w\-]+\.)+([\w]{2,})\z/i
def validate_each(record, attribute, value)
return true if value =~ EMAIL_FORMAT
record.errors[attribute] << email_error_message
end
private
def email_error_message
options[:message] || I18n.t('errors.messages.email')
end
end
Custom validator test
The email validator is to be validated appropriately.
But a corresponding test object is missing for a test in isolation. Certainly a test double with all the necessary methods (#valid?, #errors etc.) as stubs also would do the job.
However, the result often tends to be complex and cumbersome. It also ends up in coupling the tests with the actual implementation.
Another proven approach is to have a dedicated Struct with the necessary interfaces as the test object:
- ActiveModel::Validations methods
- an accessor for the attribute, that has to be validated
In this example there is the Test::EmailValidatable:
# spec/lib/validators/email_validator_spec.rb
require 'rails_helper'
require 'validators/email_validator'
# Basic test object
module Test
EmailValidatable = Struct.new(:email) do
include ActiveModel::Validations
validates :email, email: true
end
end
# Validator tests with the test object
describe EmailValidator, type: :model do
subject { Test::EmailValidatable.new 'test@mail.de' }
it { is_expected.to be_valid }
context 'without @' do
it 'is invalid' do
subject.email = 'mail.de'
subject.valid?
expect(subject.errors[:email]).to match_array('not an email')
end
end
# More tests
end
The test object should be structured in a way, so that it can back each validator test:
rspec spec/lib/validators/email_validator_spec.rb
and cover the expected behavior:
..
Finished in 0.00848 seconds (files took 2.18 seconds to load)
2 examples, 0 failures