RailsでCustom validatorをテストする

TL;DR ActiveModel::Validationsをincludeした適当なクラスをつくれば良い。

Custom validatorはこういうやつ

例えば、DBのentriesテーブルのデータを扱うEntryモデルをつくっているとする。Entryモデルにはurlという属性が存在するが、urlにはHTTPかHTTPSでかつホスト名が存在するようなURLしか入れたくない。EntryモデルはActiveRecordを利用して実装されているが、標準で利用できるValidationだけではこの要求を実現できないと考える。

そこでCustom validatorと呼ばれる機能を利用し、独自のValidationのルールをつくる。独自でこれを行うには、モデルクラスに簡単なメソッドを定義して呼び出してもらう方法や、#validateメソッドを実装したクラスを用意する方法、#validate_eachメソッドを実装したクラスを用意する方法などがある。ここでは最後のものを利用する。ActiveModel::EachValidatorというのを継承すれば良いとのこと。

できたのが以下のもの。.validatesメソッドの引数に渡している:full_http_urlと上例で新たに定義したクラスの名前が一致しているので、勝手に呼び出してくれる。

# Public: Validates if given value is valid and full HTTP URL.
#
# Examples
#
#   class Entry < ActiveRecord::Base
#     validates :url, full_http_url: true
#   end
#
class FullHttpUrlValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    begin
      uri = URI.parse(value)
    rescue URI::InvalidURIError
      uri = nil
    end
    if uri.nil? || uri.host.nil? || !uri.is_a?(URI::HTTP)
      record.errors.add(attribute, :invalid_url)
    end
  end
end

Custom validatorのテストを書く

それで表題の通りテストを書きたい訳だけれど、Entryクラスをそのままテストに利用してしまうのはあまり良い方針とは言えない。そこで、テスト用にダミーのモデルクラスをつくってテストすることにする。

ダミーのモデルクラスではEntryクラスと同じように.validatesを呼び出せるようなものにする必要がある。これには、クラス名が存在し(定数に挿入されているか.nameが呼び出せればOK)、かつActiveModel::Validationsがincludeされているクラスを用意すれば良い。テストのためにグローバルな名前空間にクラスを定義してしまうのは良くないから、適当にクラスオブジェクトをつくって変数か何かで扱う。クラスをつくるにはClass.newを使ってもいいけれど、外部からテスト用にURLを与える機能も実装したいので、丁度良さそうなStruct.newを使う。最終的にできたのが以下のコード。

require 'spec_helper'

describe FullHttpUrlValidator do
  let(:model_class) do
    Struct.new(:url) do
      include ActiveModel::Validations

      def self.name
        'DummyModel'
      end

      validates :url, full_http_url: true
    end
  end

  describe '#validate' do
    subject do
      model_class.new(url)
    end

    context 'with empty string' do
      let(:url) do
        ''
      end
      it { should_not be_valid }
    end

    context 'with nil' do
      let(:url) do
        nil
      end
      it { should_not be_valid }
    end

    context 'without host' do
      let(:url) do
        'http:///about'
      end
      it { should_not be_valid }
    end

    context 'without http(s) scheme' do
      let(:url) do
        'ftp://example.com'
      end
      it { should_not be_valid }
    end

    context 'with https' do
      let(:url) do
        'https://example.com'
      end
      it { should be_valid }
    end

    context 'with full HTTP URL' do
      let(:url) do
        'http://example.com'
      end
      it { should be_valid }
    end
  end
end
FullHttpUrlValidator
  #validate
    with empty string
      should not be valid
    with nil
      should not be valid
    without host
      should not be valid
    without http(s) scheme
      should not be valid
    with https
      should be valid
    with full HTTP URL
      should be valid