开发者

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
0

上一篇:

下一篇:

精彩评论

暂无评论...
验证码 换一张
取 消

最新问答

问答排行榜