1

I'm using Rails 7 and Ruby 3.1, and Shoulda Matchers for tests, but not Active Record, for I do not need a database. I want to validate numericality. However, validations do not work. It looks like input is transformed into integer, instead of being validated. I do not understand why that happens.

My model:

# app/models/grid.rb

class Grid
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :rows, :integer
  validates :rows, inclusion: { in: 1..50 }, numericality: { only_integer: true }
  
  # Some other code...
end

My test:

# spec/models/grid_spec.rb 

RSpec.describe Grid, type: :model do   
  describe 'validations' do                      
    shared_examples 'validates' do |field, range|
      it { is_expected.to validate_numericality_of(field).only_integer }
      it { is_expected.to validate_inclusion_of(field).in_range(range) }
    end

    include_examples 'validates', 'rows', 1..50
  end

  # Some other tests...
end

Nonetheless, my test fails:

Grid validations is expected to validate that :rows looks like an integer
     Failure/Error: it { is_expected.to validate_numericality_of(field).only_integer }
     
       Expected Grid to validate that :rows looks like an integer, but this
       could not be proved.
         After setting :rows to ‹"0.1"› -- which was read back as ‹0› -- the
         matcher expected the Grid to be invalid and to produce the validation
         error "must be an integer" on :rows. The record was indeed invalid,
         but it produced these validation errors instead:
     
         * rows: ["is not included in the list"]
     
         As indicated in the message above, :rows seems to be changing certain
         values as they are set, and this could have something to do with why
         this test is failing. If you've overridden the writer method for this
         attribute, then you may need to change it to make this test pass, or
         do something else entirely.

Update

Worse than before, because tests are working but code is not actually working.

# app/models/grid.rb

class Grid
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :rows, :integer
  validates :rows, presence: true, numericality: { only_integer: true, in: 1..50 }

  # Some other code...
end
# spec/models/grid_spec.rb 

RSpec.describe Grid, type: :model do   
  describe 'validations' do                      
    shared_examples 'validates' do |field, type, range|
      it { is_expected.to validate_presence_of(field) } 
      it do                                                                                    
        validate = validate_numericality_of(field)
          .is_greater_than_or_equal_to(range.min)
          .is_less_than_or_equal_to(range.max)
          .with_message("must be in #{range}")              
  
        is_expected.to type == :integer ? validate.only_integer : validate
      end
    end

    include_examples 'validates', 'rows', :integer, 1..50
  end

  # Some other tests...
end
Chiara Ani
  • 918
  • 7
  • 25
  • The thing is that providing non-integer obviously breaks the inclusion validation too - just the error is different (and validators are applied in the order they are declared). So trying to break the numericality validation matcher breaks the inclusion one first. And here comes the real question: why do you declare the 2nd validation if the 1st one implies it? – Konstantin Strukov Jul 13 '22 at 18:19
  • @KonstantinStrukov but non integers don't break the inclusion: `(1..50).cover?(11.11) # => true`. besides, *rows* is typecasted to integer, so strings work too. – Alex Jul 13 '22 at 20:34

2 Answers2

1

The underlying problem (or maybe not a problem) is that you're typecasting rows attribute to integer.

>> g = Grid.new(rows: "num"); g.validate
>> g.errors.as_json
=> {:rows=>["is not included in the list"]}

# NOTE: only inclusion errors shows up, making numericality test fail.

To make it more obvious, let's remove inclusion validation:

class Grid
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :rows, :integer
  validates :rows, numericality: { only_integer: true }
end

Still, this does not fix numericality test:

# NOTE: still valid
>> Grid.new(rows: "I'm number, trust me").valid?
=> true

# NOTE: because `rows` is typecasted to integer, it will
#       return `0` which is numerical.

>> Grid.new(rows: "I'm number, trust me").rows
=> 0

>> Grid.new(rows: 0.1).rows
=> 0

# NOTE: keep in mind, this is the current behavior, which
#       might be unexpected.

In the test validate_numericality_of, first of all, expects an invalid record with "0.1", but grid is still valid, which is why it fails.

Besides replacing the underlying validations, like you did, there are a few other options:

You could replace numericality test:

it { expect(Grid.new(rows: "number").valid?).to eq true }
it { expect(Grid.new(rows: "number").rows).to eq 0 }

# give it something not typecastable, like a class.
it { expect(Grid.new(rows: Grid).valid?).to eq false }

Or remove typecast:

attribute :rows

Update

Seems like you're trying to overdo it with validations and typecasting. From what I can see the only issue is just one test, everything else works fine. Anyway, I've came up with a few more workarounds:

class Grid
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :rows, :integer
  validates :rows, inclusion: 1..50, numericality: { only_integer: true }

  def rows= arg
    # NOTE: you might want to raise an error instead,
    #       because this validation will go away if you run
    #       validations again.
    errors.add(:rows, "invalid") if (/\d+/ !~ arg.to_s)
    super
  end
end
class Grid
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :rows, :integer
  validates :rows, inclusion: 1..50, numericality: { only_integer: true }

  validate :validate_rows_before_type_cast
  def validate_rows_before_type_cast
    rows = @attributes.values_before_type_cast["rows"]
    errors.add(:rows, :not_a_number) if rows.is_a?(String) && rows !~ /^\d+$/
  end
end
class Grid
  include ActiveModel::Model
  include ActiveModel::Attributes
  include ActiveRecord::AttributeMethods::BeforeTypeCast

  attribute :rows, :integer
  validates :rows, inclusion: 1..50

  # NOTE: this does show "Rows before type cast is not a number"
  #       maybe you'd want to customize the error message.
  validates :rows_before_type_cast, numericality: { only_integer: true }
end

https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/BeforeTypeCast.html

Alex
  • 16,409
  • 6
  • 40
  • 56
0

My solution was dividing validations and typecasts into models.

# app/models/grid.rb
class Grid
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :rows, :integer
end
# app/models/grid_data.rb
class GridData
  include ActiveModel::Model
  include ActiveModel::Attributes
  
  Grid.attribute_names.each { |name| attribute name }

  validates(*attribute_names, presence: true)
  validates :rows, numericality: { only_integer: true, in: 1..50 }
end 

Specs

# spec/models
RSpec.describe Grid, type: :model do
  let(:grid) { described_class.new(**attributes) }

  describe 'type cast' do
    let(:attributes) { default(rows: '2') }

    it 'parses string valid arguments to integer or float' do
      expect(grid.rows).to eq 2
    end
  end
end
RSpec.describe GridData, type: :model do
  it 'has same attributes as Grid model' do
    expect(described_class.attribute_names).to eq Grid.attribute_names
  end

  describe 'validations' do
    shared_examples 'validates' do |field, type, range|
      it { is_expected.to validate_presence_of(field) }
        
      it do
        validate = validate_numericality_of(field)
        validate = validate.only_integer if type == :integer
        expect(subject).to validate
      end
        
      it do
        expect(subject).to validate_inclusion_of(field)
          .in_range(range)
          .with_message("must be in #{range}")
      end
    end

    include_examples 'validates', 'rows', :integer, 1..50
  end
end

Controller

# app/controller/grids_controller.rb
class GridsController < ApplicationController
  def create
    @grid_data = GridData.new(**grid_params)
    
    if @grid_data.valid?
      play
    else 
      render :new, status: :unprocessable_entity
    end 
  end

  private
      
  def grid_params
    params.require(:grid_data).permit(*Grid.attribute_names)
  end     
      
  def play
    render :play, status: :created
    Grid.new(**@grid_data.attributes).play
  end
end
Chiara Ani
  • 918
  • 7
  • 25