44 votes

Comment tester un validateur personnalisé ?

J'ai le validateur suivant :

# 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

J'aimerais pouvoir tester ceci dans RSpec à l'intérieur de mon répertoire lib. Le problème jusqu'à présent est que je ne suis pas sûr de savoir comment initialiser un fichier de type EachValidator .

76voto

Neal Points 1594

Je ne suis pas un grand fan de l'autre approche car elle lie le test trop étroitement à l'implémentation. De plus, c'est assez difficile à suivre. C'est l'approche que j'utilise finalement. Gardez à l'esprit que c'est une simplification grossière de ce que mon validateur fait réellement... je voulais juste le démontrer plus simplement. Il y a certainement des optimisations à faire

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

Spec associé :

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

En fait, mon approche consiste à créer un faux objet "Validatable" afin de pouvoir tester les résultats sur cet objet plutôt que d'avoir des attentes pour chaque partie de l'implémentation.

2 votes

J'aime cela car je suis un fan des tests de validation via le module ActiveModel::Validations. Sinon, vous liez le test à l'implémentation d'ActiveModel, ce qui est fragile.

1 votes

Avec Rails 4.1.6, j'obtenais ":attributes cannot be blank" jusqu'à ce que je modifie l'appel validates_with pour inclure le nom de l'attribut, par exemple. validates_with OmniauthValidator, attributes: 'omniauth_provider'

0 votes

Avec Rails 4.1.7, j'ai pu contourner l'erreur ":attribute cannot be blank" avec validates :omniauth_provider, omniauth: true

46voto

Gazler Points 23588

Voici une spécification rapide que j'ai élaborée pour ce fichier et qui fonctionne bien. Je pense que le stubbing pourrait probablement être nettoyé, mais j'espère que cela sera suffisant pour vous aider à démarrer.

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

0 votes

Fonctionne très bien. Je ne connaissais pas mock('model'), je vais essayer d'en savoir plus.

0 votes

rspec.info/documentation/mocks Il s'agit de l'ancienne documentation RSpec, mais le constructeur mock reste le même. model est juste un identifiant pour le mock.

0 votes

Vous pouvez également le faire de manière fonctionnelle avec des usines.

19voto

Kris Points 3781

Je recommanderais de créer une classe anonyme à des fins de test, par exemple :

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

Cela permettra d'éviter les classes codées en dur telles que Validatable qui pourrait être référencée dans plusieurs spécifications, ce qui entraînerait un comportement inattendu et difficile à déboguer en raison des interactions entre des validations non liées, que vous essayez de tester de manière isolée.

6voto

Koen. Points 3570

Inspiré par la réponse de @Gazler, j'ai trouvé l'idée suivante : mocker le modèle, mais en utilisant la fonction ActiveModel::Errors comme objet d'erreur. Cela réduit considérablement le mocking.

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

4voto

Un autre exemple, avec l'extension d'un objet au lieu de créer une nouvelle classe dans la spécification. BitcoinAddressValidator est un validateur personnalisé ici.

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

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X