Rails Minitest one model validation causes ArgumentError: You need to supply at least one validation

432 Views Asked by At

In my Rails6 app I've got two model validations which I want to test by Minitest:

class Portfolio < ApplicationRecord
  validates :name, :status, presence: true
  validates :initial_return do |record, attr, value|
    record.errors.add(attr, 'Add value between -100 and 100') unless value >= -100 && value <= 100
  end
end

Minitest:

class PortfolioTest < ActiveSupport::TestCase
  setup do
    @portfolio = Portfolio.create(name: Faker::Bank.name)
  end

  test 'invalid PortfolioSpotlightFigure, does not fit the range (-100, 100)' do
    @portfolio.initial_return = -101
    assert_not @portfolio.valid?
    @portfolio.initial_return = 101
    assert_not @portfolio.valid?
    @portfolio.initial_return = 50
    assert @portfolio.valid?
  end

  context 'validations' do
    should validate_presence_of(:name)
  end
end

Minitest gives the same error for both cases:

ArgumentError: You need to supply at least one validation

But when I remove validation for :initial_return field from Portfolio model:

  validates :initial_return do |record, attr, value|
    record.errors.add(attr, 'Add value between -100 and 100') unless value >= -100 && value <= 100

the test will pass for the validate_presence_of(:name) which means that I incorrectly defined that validation. What did I missed?

2

There are 2 best solutions below

0
max On BEST ANSWER

You don't need to reinvent the wheel

class Portfolio < ApplicationRecord
  validates :name, :status, presence: true
  validates :initial_return,
    numericality: {
      greater_than_or_equal_to: -100,
      less_than_or_equal_to: 100
    }
end

And stop carpet bombing your validations in your tests. Test the actual validation and not if the entire object is valid/invalid which leads to false positives and negatives. For example:

  test 'invalid PortfolioSpotlightFigure, does not fit the range (-100, 100)' do
    @portfolio.initial_return = -101
    # these will pass even if you comment out the validation on initial_return as 
    # status is nil
    assert_not @portfolio.valid? 
    @portfolio.initial_return = 101
    assert_not @portfolio.valid?
    # Will fail because status is nil
    @portfolio.initial_return = 50
    assert @portfolio.valid?
  end

As you can see the test failures will tell you nothing about why the model is valid/invalid.

Instead use one assertion per test and test the actual validation:

class PortfolioTest < ActiveSupport::TestCase
  setup do
    # you dont need to insert records into the db to test associations
    @portfolio = Portfolio.new
  end

  test 'initial return over 100 is invalid' do
    # arrange
    @portfolio.initial_return = 200
    # act 
    @portfolio.valid?
    # assert
    assert_includes(@portfolio.errors.full_messages, "Initial return must be less than or equal to 100")
  end

  test 'initial return below -100 is invalid' do
    # arrange
    @portfolio.initial_return = -200
    # act 
    @portfolio.valid?
    # assert
    assert_includes(@portfolio.errors.full_messages, "Initial return must be greater than or equal to -100")
  end

  test 'an initial return between -100 and 100 is valid' do
    # arrange
    @portfolio.initial_return = 50
    # act 
    @portfolio.valid?
    # assert
    refute(@portfolio.errors.has_key?(:intial_return))
  end

  # ...
end

With shoulda you should be able to use the validates_numericality_of matcher:

should validate_numericality_of(:initial_return).
            is_greater_than_or_equal_to(-100).
            is_less_than_or_equal_to(100)
3
Denny Mueller On

@portfolio = Portfolio.create(name: Faker::Bank.name) in the setup block is expected to already fail.

I don't know if it leads to the actual error but you can't create the object when you don't provide the initial initial_return. Since it runs against the validation itself.

Because the test case for the numerical range runs you need to make sure that you initial object is valid. That's why it didn't fail when you removed the initial_return validation because the setup block was successful without the validation. You were just looking at the wrong end.

So you either use build which does not persist the object in the database and does not run the validation initially

@portfolio = Portfolio.build(name: Faker::Bank.name)

or if in case you want to persist the object in the database you have to make sure the setup object is valid

@portfolio = Portfolio.create(name: Faker::Bank.name, initial_return: 50)