How to test a custom validator?

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, '[email protected]')
        }.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

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', '@', '[email protected].', '.', ' '].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
    ['[email protected]', '[email protected]'].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.


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', '[email protected]')
  end

  it 'should validate invalid address' do
    @mock.errors[].should_receive('<<')
    @validator.validate_each(@mock, 'email', 'notvalid')
  end  
end

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