Testing custom validator

A custom validator solves the problem of repeated custom validation logic. But is also allows to improve its testability.
The following is an example of testing a custom validator in isolation.

Model

The example is about validating a users email address format.
The User model:

rails g model User email:string

The email attribute has to correspond to a valid email format. The model benefits from a custom email validator:

# models/user.rb
require 'email_validator'

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

Custom validator implementation

The custom email validator with a plausible regular expression for 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

The email validator is to be validated appropriately.
But a corresponding test object is missing for a test in isolation. Certainly a test double with all the necessary methods (#valid?, #errors etc.) as stubs also would do the job. However, the result often tends to be complex and cumbersome. It also ends up in coupling the tests with the actual implementation.
Another proven approach is to have a dedicated Struct with the necessary interfaces as the test object:

In this example there is the Test::EmailValidatable:

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

# Basic test object
module Test
  EmailValidatable = Struct.new(:email) do
    include ActiveModel::Validations

    validates :email, email: true
  end
end

# Validator tests with the test object
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

  # More tests
end

The test object should be structured in a way, so that it can back each validator test:

rspec spec/lib/validators/email_validator_spec.rb

and cover the expected behavior:

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