Custom Validator testen

Custom Validatorklassen lösen das Problem der Wiederholung von Custom Validierungslogik. Aber auch die bessere Testbarkeit der Validatoren ist ein Grund.
Im Folgenden wird anhand eines Beispiels ein Custom Validator in Isolation getestet.

Model

In dem Beispiel geht es um die Validierung der Email Addresse von Usern.
Das User Modell:

rails g model User email:string

Das Attribut email muss einem validen Emailformat entsprechen. Dafür wird ein Custom Email-Validator verwendet:

# models/user.rb
require 'email_validator'

class User < ApplicationRecord
  validates :email, email: true
end

Custom Validator Implementierung

Der Custom Email-Validator mit einer plausiblen Regular Expression für Emails:

# lib/validators/email_validator.rb
class EmailValidator < ActiveModel::EachValidator
  EMAIL_FORMAT = /\A([\w\.%\+\-]+)@([\w\-]+\.)+([\w]{2,})\z/i

  def validate_each(record, attribute, value)
    return true if value =~ EMAIL_FORMAT
    record.errors[attribute] << email_error_message
  end

  private

  def email_error_message
    options[:message] || I18n.t('errors.messages.email')
  end
end

Custom Validator Test

Nun soll der Email-Validator getestet werden.
Allerdings fehlt für den Test in Isolation ein entsprechendes Testobjekt. Sicherlich könnte dafür ein Test Double benutzt werden, das die benötigten Methoden (#valid?, #errors etc.) als Stubs enthält. Allerdings ist das oft aufwendiger und bindet die Tests zu sehr an die eigentliche Implementierung.
Ein anderer etablierter Ansatz ist, das Testobjekt als Struct mit den notwendigen Schnittstellen:

zu definieren. In diesem Beispiel ist es das Test::EmailValidatable:

# spec/lib/validators/email_validator_spec.rb
require 'rails_helper'
require 'validators/email_validator'

# Rudimentäres Test Objekt
module Test
  EmailValidatable = Struct.new(:email) do
    include ActiveModel::Validations

    validates :email, email: true
  end
end

# Validator Tests mit dem Test Objekt
describe EmailValidator, type: :model do
  subject { Test::EmailValidatable.new 'test@mail.de' }

  it { is_expected.to be_valid }

  context 'without @' do
    it 'is invalid' do
      subject.email = 'mail.de'
      subject.valid?
      expect(subject.errors[:email]).to match_array('not an email')
    end
  end

  # Weitere Testfälle
end

Das Testobjekt sollte so strukturiert sein, dass sämtliche Validator Tests darauf basieren können:

rspec spec/lib/validators/email_validator_spec.rb

und das erwartete Verhalten widerspiegeln:

..
Finished in 0.00848 seconds (files took 2.18 seconds to load)
2 examples, 0 failures