How to test a custom validator?
Asked Answered
G

7

45

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.

Gumwood answered 12/10, 2011 at 17:38 Comment(0)
S
46

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
Studdingsail answered 12/10, 2011 at 18:13 Comment(6)
Works great. Didn't know about mock('model') ill try to learn more about that.Gumwood
rspec.info/documentation/mocks That is the old RSpec documentation, but the mock constructor remains the same. model is just an identifier for the mock.Studdingsail
You can also do it functionally with factories.Lizbeth
I think a factory is overkill. We just want an otherwise empty class to contain the errors array.Pitsaw
It works great! I just did a different stub: @model = mock('model', errors: ActiveModel::Errors.new({}))Murr
On newer versions of Rails (I'm using 4.1) you'll need to specify some attributes to your validator to avoid getting an ArgumentError - it doesn't matter what you pass in as long as the attributes aren't empty, so something like @validator = EmailValidator.new({:attributes => { :foo => :bar }}) will do the trick.Neuman
L
79

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

Lascivious answered 19/7, 2013 at 6:9 Comment(6)
I like this because I'm a fan of testing validation through the ActiveModel::Validations module. Otherwise you're tying the test to ActiveModel's implementation which is fragile.Keil
With Rails 4.1.6, I got ":attributes cannot be blank" until I changed the validates_with call to include the attribute name, e.g. validates_with OmniauthValidator, attributes: 'omniauth_provider'Sholokhov
With Rails 4.1.7, I was able to get around the ":attribute cannot be blank" error with validates :omniauth_provider, omniauth: trueEadith
Hey, I would love to see that optimizations! There is not much documentation about validation testing out there.Peng
Nice, I think this is a much cleaner solution than the accepted answer.Cracksman
In rspec 3+ (I think is when it changed) you need the rspec-collection_matchers gem to use have(1).error_on.Tarra
S
46

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
Studdingsail answered 12/10, 2011 at 18:13 Comment(6)
Works great. Didn't know about mock('model') ill try to learn more about that.Gumwood
rspec.info/documentation/mocks That is the old RSpec documentation, but the mock constructor remains the same. model is just an identifier for the mock.Studdingsail
You can also do it functionally with factories.Lizbeth
I think a factory is overkill. We just want an otherwise empty class to contain the errors array.Pitsaw
It works great! I just did a different stub: @model = mock('model', errors: ActiveModel::Errors.new({}))Murr
On newer versions of Rails (I'm using 4.1) you'll need to specify some attributes to your validator to avoid getting an ArgumentError - it doesn't matter what you pass in as long as the attributes aren't empty, so something like @validator = EmailValidator.new({:attributes => { :foo => :bar }}) will do the trick.Neuman
S
21

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.

Siderosis answered 19/4, 2017 at 13:53 Comment(1)
Great answer. In Rails 6 I had to provide a model_name method for ActiveRecord. Inside the Class.new block I added a method from this answer: def self.model_name ActiveModel::Name.new(self, nil, 'temp') endHevesy
F
6

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
Foreignism answered 8/6, 2020 at 9:37 Comment(1)
This is the nice answer for 2020 (rspec-rails 4.0).Longawa
V
4

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
Villar answered 12/8, 2015 at 8:52 Comment(0)
G
4

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
Geochemistry answered 16/8, 2015 at 20:19 Comment(1)
You don't need to include ActiveModel::Model, also it might be better to create an anonymous class such as subject { Class.new { include ActiveModel::Validations; attr_accessor :slug; validate :slug, slug: true }.new }Siderosis
I
0

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) { "[email protected]" }

    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
Isabel answered 6/12, 2022 at 10:23 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.