How to test a custom validator?
I have the following validator:
# Source: http://guides.rubyonrails.org/active_record_validations_callbacks.html#custom-validators
# app/validators/email_validator.rb
class EmailValidator < ActiveModel::EachValidator
def validate_each(object, attribute, value)
unless value =~ /^([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})$/i
object.errors[attribute] << (options[:message] || "is not formatted properly")
end
end
end
I would like to be able to test this in RSpec inside of my lib directory. The problem so far is I am not sure how to initialize 开发者_开发百科an EachValidator
.
I am not a huge fan of the other approach because it ties the test too close to the implementation. Also, it's fairly hard to follow. This is the approach I ultimately use. Please keep in mind that this is a gross oversimplification of what my validator actually did... just wanted to demonstrate it more simply. There are definitely optimizations to be made
class OmniauthValidator < ActiveModel::Validator
def validate(record)
if !record.omniauth_provider.nil? && !%w(facebook github).include?(record.omniauth_provider)
record.errors[:omniauth_provider] << 'Invalid omniauth provider'
end
end
end
Associated Spec:
require 'spec_helper'
class Validatable
include ActiveModel::Validations
validates_with OmniauthValidator
attr_accessor :omniauth_provider
end
describe OmniauthValidator do
subject { Validatable.new }
context 'without provider' do
it 'is valid' do
expect(subject).to be_valid
end
end
context 'with valid provider' do
it 'is valid' do
subject.stubs(omniauth_provider: 'facebook')
expect(subject).to be_valid
end
end
context 'with unused provider' do
it 'is invalid' do
subject.stubs(omniauth_provider: 'twitter')
expect(subject).not_to be_valid
expect(subject).to have(1).error_on(:omniauth_provider)
end
end
end
Basically my approach is to create a fake object "Validatable" so that we can actually test the results on it rather than have expectations for each part of the implementation
Here's a quick spec I knocked up for that file and it works well. I think the stubbing could probably be cleaned up, but hopefully this will be enough to get you started.
require 'spec_helper'
describe 'EmailValidator' do
before(:each) do
@validator = EmailValidator.new({:attributes => {}})
@mock = mock('model')
@mock.stub('errors').and_return([])
@mock.errors.stub('[]').and_return({})
@mock.errors[].stub('<<')
end
it 'should validate valid address' do
@mock.should_not_receive('errors')
@validator.validate_each(@mock, 'email', 'test@test.com')
end
it 'should validate invalid address' do
@mock.errors[].should_receive('<<')
@validator.validate_each(@mock, 'email', 'notvalid')
end
end
I would recommend creating an anonymous class for testing purposes such as:
require 'spec_helper'
require 'active_model'
require 'email_validator'
RSpec.describe EmailValidator do
subject do
Class.new do
include ActiveModel::Validations
attr_accessor :email
validates :email, email: true
end.new
end
describe 'empty email addresses' do
['', nil].each do |email_address|
describe "when email address is #{email_address}" do
it "does not add an error" do
subject.email = email_address
subject.validate
expect(subject.errors[:email]).not_to include 'is not a valid email address'
end
end
end
end
describe 'invalid email addresses' do
['nope', '@', 'foo@bar.com.', '.', ' '].each do |email_address|
describe "when email address is #{email_address}" do
it "adds an error" do
subject.email = email_address
subject.validate
expect(subject.errors[:email]).to include 'is not a valid email address'
end
end
end
end
describe 'valid email addresses' do
['foo@bar.com', 'foo@bar.bar.co'].each do |email_address|
describe "when email address is #{email_address}" do
it "does not add an error" do
subject.email = email_address
subject.validate
expect(subject.errors[:email]).not_to include 'is not a valid email address'
end
end
end
end
end
This will prevent hardcoded classes such as Validatable
, which could be referenced in multiple specs, resulting in unexpected and hard to debug behavior due to interactions between unrelated validations, which you are trying to test in isolation.
Inspired by @Gazler's answer I came up with the following; mocking the model, but using ActiveModel::Errors
as errors object. This slims down the mocking quite a lot.
require 'spec_helper'
RSpec.describe EmailValidator, type: :validator do
subject { EmailValidator.new(attributes: { any: true }) }
describe '#validate_each' do
let(:errors) { ActiveModel::Errors.new(OpenStruct.new) }
let(:record) {
instance_double(ActiveModel::Validations, errors: errors)
}
context 'valid email' do
it 'does not increase error count' do
expect {
subject.validate_each(record, :email, 'test@example.com')
}.to_not change(errors, :count)
end
end
context 'invalid email' do
it 'increases the error count' do
expect {
subject.validate_each(record, :email, 'fakeemail')
}.to change(errors, :count)
end
it 'has the correct error message' do
expect {
subject.validate_each(record, :email, 'fakeemail')
}.to change { errors.first }.to [:email, 'is not an email']
end
end
end
end
One more example, with extending an object instead of creating new class in the spec. BitcoinAddressValidator is a custom validator here.
require 'rails_helper'
module BitcoinAddressTest
def self.extended(parent)
class << parent
include ActiveModel::Validations
attr_accessor :address
validates :address, bitcoin_address: true
end
end
end
describe BitcoinAddressValidator do
subject(:model) { Object.new.extend(BitcoinAddressTest) }
it 'has invalid bitcoin address' do
model.address = 'invalid-bitcoin-address'
expect(model.valid?).to be_falsey
expect(model.errors[:address].size).to eq(1)
end
# ...
end
Using Neals great example as a basis I came up with the following (for Rails and RSpec 3).
# /spec/lib/slug_validator_spec.rb
require 'rails_helper'
class Validatable
include ActiveModel::Model
include ActiveModel::Validations
attr_accessor :slug
validates :slug, slug: true
end
RSpec.describe SlugValidator do
subject { Validatable.new(slug: slug) }
context 'when the slug is valid' do
let(:slug) { 'valid' }
it { is_expected.to be_valid }
end
context 'when the slug is less than the minimum allowable length' do
let(:slug) { 'v' }
it { is_expected.to_not be_valid }
end
context 'when the slug is greater than the maximum allowable length' do
let(:slug) { 'v' * 64 }
it { is_expected.to_not be_valid }
end
context 'when the slug contains invalid characters' do
let(:slug) { '*' }
it { is_expected.to_not be_valid }
end
context 'when the slug is a reserved word' do
let(:slug) { 'blog' }
it { is_expected.to_not be_valid }
end
end
If it's possible to not use stubs I would prefer this way:
require "rails_helper"
describe EmailValidator do
let(:user) { build(:user, email: email) } # let's use any real model
let(:validator) { described_class.new(attributes: [:email]) } # validate email field
subject { validator.validate(user) }
context "valid email" do
let(:email) { "person@mail.com" }
it "should be valid" do
# with this expectation we isolate specific validator we test
# and avoid leaking of other validator errors rather than with `user.valid?`
expect { subject }.to_not change { user.errors.count }
expect(user.errors[:email]).to be_blank
end
end
context "ivalid email" do
let(:email) { "invalid.com" }
it "should be invalid" do
expect { subject }.to change { user.errors.count }
# Here we can check message
expect(user.errors[:email]).to be_present
expect(user.errors[:email].join(" ")).to include("Email is invalid")
end
end
end
精彩评论