2

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?

rmcsharry
  • 5,363
  • 6
  • 65
  • 108

3 Answers3

1

I think you should not aim for testing whether the model is using a specific validator. Rather check if the model is valid/invalid in specific cases. In other words, you should be able to test the behaviour of the model without knowing the implementation.

So in this case, you should setup you model correctly with you 'exactly' option for the validator and test if the model validation is sufficient overall.

On the other hand, if you are worried about that someone will misuse the validator in the future and 'exactly' is a required option for the validator, then you should raise error every time when the option is not present and test the validator in isolation like explained here: How to test a custom validator?

kriskova
  • 568
  • 3
  • 7
  • Thanks for the advice. The validator is already tested in isolation - it has its own unit tests. I'm getting back into rspec after a five year break and your 2nd paragraph made me realise my error. I have not setup the model correctly in the test, so thanks for pointing that out! – rmcsharry May 15 '19 at 21:28
  • 1
    I'm a little stumped, but I would use [`pry-byebug`](https://github.com/deivid-rodriguez/pry-byebug) to step through each line of DigitsValidator to ensure that everything is getting set correctly. If you add `binding.pry` to the top of your `error_msg` method you can step through each line by running `next` in the Pry console. You can also descend in and out of methods by using `step` or `up`. – Elliot Winkler May 15 '19 at 23:10
  • @kriskova I guess I was wrong in my first comment. The model is setup correctly. :( – rmcsharry May 16 '19 at 06:23
1

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
rmcsharry
  • 5,363
  • 6
  • 65
  • 108
Mario
  • 870
  • 9
  • 20
1

I like the idea of not including tests on the model that assume knowledge of exactly what the custom validator is validating. (Otherwise, we'll be repeating logic in the tests for the custom validators, and the tests for the model.)

I solved this by using Mocha (mocking library for Ruby) to set up expectations that the validate_each method of each my custom validators were being called on the correct corresponding field of my model. Simplified example:

Model class:

class User
    include ActiveModel::Model

    attr_accessor :first_name, :last_name

    validates :first_name, first_name: true
    validates :last_name, last_name: true
end

Custom validator classes:

class FirstNameValidator < ActiveModel::EachValidator
    def validate_each(record, attribute, value)
        # ...
    end
end

class LastNameValidator < ActiveModel::EachValidator
    def validate_each(record, attribute, value)
        # ...
    end
end

Model test class:

class UserTest < ActiveSupport::TestCase
    def test_custom_validators_called_on_the_appropriate_fields
        user = User.new(first_name: 'Valued', last_name: 'Customer')

        FirstNameValidator.any_instance.expects(:validate_each).with(user, :first_name, 'Valued')
        LastNameValidator.any_instance.expects(:validate_each).with(user, :last_name, 'Customer')

        assert_predicate user, :valid?
    end
end
Jon Schneider
  • 25,758
  • 23
  • 142
  • 170