Search code examples
ruby-on-railsvalidationrspecruby-on-rails-5customvalidator

Rails 5.2 rspec - How to test if a model is actually using a custom validator?


I have created a custom validator which has it's own specific unit tests to check that it works.

Using should-matchers there was a suggestion to add a validates_with matcher, so you could write:

subject.validates_with(:custom_validator)

Quite rightly the suggestion was declined, since it does not really test the behaviour of the model.

But my model has 4 fields that use the custom validator, and I want that behaviour to be tested - ie that those 4 fields are being validated, just as I am testing that they are being validated for presence:

describe '#attribute_name' do
  it { is_expected.to validate_presence_of(:attribute_name) }
end

So how can I write a test that basically does the same thing, something sort of like this:

describe '#attribute_name' do
  it { is_expected.to use_custom_validator_on(:attribute_name) }
end

This question asks the same thing and the answer suggests building a test model. However, my validator requires an option, it is used like this:

\app\models\fund.rb

class Fund < ActiveRecord
  validates :ein, digits: { exactly: 9 }
end

So if I build a test model, and test it as suggested:

it 'is has correct number of digits' do
  expect(build(:fund, ein: '123456789')).to be_valid
end

it 'is has incorrect number of digits' do
  expect(build(:fund, ein: '123').to be_invalid
end

I receive RecordInvalid error (from my own validator! lol) saying I did not supply the required option for the validator. That option is called 'exactly'.

1) Fund#ein validates digits
     Failure/Error: raise ActiveRecord::RecordInvalid # option :exactly was not provided (incorrect usage)

     ActiveRecord::RecordInvalid:
       Record invalid

So is Rspec not 'seeing' the value '9' defined in the model file?

Obviously it makes no sense to define that in the test as that is the defined behaviour I am trying to test for. Think of it like the validates_length_of testing for the { length: x } option.

Surely there must be a way to test that this custom validator option is set on the model?

The validator code

class DigitsValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    return if value.blank?

    length = options[:exactly]
    regex = /\A(?!0{#{length}})\d{#{length}}\z/
    return unless value.scan(regex).empty?

    record.errors[attribute] << (options[:message] || error_msg(length))
  end

  private

  def error_msg(length)
    I18n.t('activerecord.errors.custom.digits_attr_invalid', length: length) if length
    raise ActiveRecord::RecordInvalid # option :exactly was not provided (incorrect usage)
  end
end

Interesting side note

Obviously if I remove the 'raise' line from the DigitsValidator then both the tests succeed. Is there something wrong with my code that I cannot see?


Solution

  • I think you would have to add a return statement, no? :-)

     def error_msg(length)
       return I18n.t('activerecord.errors.custom.digits_attr_invalid', length: length) if length
       raise ActiveRecord::RecordInvalid # option :exactly was not provided (incorrect usage)
     end
    

    Alternatively, remove that method and use a guard after setting length:

      class DigitsValidator < ActiveModel::EachValidator
        def validate_each(record, attribute, value)
          return if value.blank?
    
          length = options[:exactly]
          raise ActiveRecord::RecordInvalid if length.nil?
    
          regex = /\A(?!0{#{length}})\d{#{length}}\z/
          return unless value.scan(regex).empty?
    
          record.errors[attribute] << 
            (options[:message] || 
              I18n.t('activerecord.errors.custom.digits_attr_invalid', length: length))
          end
        end