orangesoda
orangesoda

Reputation: 127

Rails Rspec - How to set polymorphic has_many association

I have a model (payment) that belongs to another model (event), via a polymorphic association.

Some tests are failing because the owner model (event) is accessed by the payment model in validations, but the event is returning nil. All the features work fine when testing app directly in the browser. I added some more comments to payment.rb below.

I've tried defining the association in the factories, but no luck.

What is the best way to set up this association in the spec?

# models/event.rb

class Event < ApplicationRecord

  has_many :payments, as: :payable, dependent: :destroy

end
# models/payment.rb

class Payment < ApplicationRecord

  belongs_to :payable, polymorphic: true

  validate :amount_is_valid

  def amount_is_valid

    if amount.to_i > payable.balance.to_i
      errors.add(:amount, "can't be higher than balance")
    end

  end
  
end

Both examples in this spec are failing.

# spec/models/payment_spec.rb

require 'rails_helper'

RSpec.describe Payment, type: :model do
  
  let!(:event) { FactoryBot.create(:event, event_type: 'test', total: 10000, balance: 10000) }
  let!(:user) {FactoryBot.create(:user)}
  let!(:payment) { 
    FactoryBot.build(:payment, 
      amount: 300,
      method: 'cash', 
      payer_id: user.id,
      payable_id: event.id, 
      status: 1,
    )
  }

  describe 'Association' do
    it do 
      
      # This will fail with or without this line 
      payment.payable = event

      is_expected.to belong_to(:payable)
    end

  end

  # Validation
  describe 'Validation' do

    describe '#amount_is_valid' do 
      it 'not charge more than event balance' do 

        # This will make the test pass. The actual spec has a lot more examples though,
        # would rather just set the association once.
        
        # payment.payable = event 

        payment.amount = 5000000
        payment.validate 
        expect(payment.errors[:amount]).to include("can't be higher than balance")
      end
    end
 
  end 
end

Output


# bundle exec rspec spec/models/payment_spec.rb

Randomized with seed 42748

Payment
  Association
    should belong to payable required: true (FAILED - 1)
  Validation
    #amount_is_valid
      not charge more than event balance (FAILED - 2)

Failures:

  1) Payment Association should belong to payable required: true
     Failure/Error: if amount.to_i > payable.balance.to_i
     
     NoMethodError:
       undefined method `balance' for nil:NilClass
     # ./app/models/payment.rb:9:in `amount_is_valid'
     # ./spec/models/payment_spec.rb:23:in `block (3 levels) in <top (required)>'
     # ./spec/rails_helper.rb:80:in `block (3 levels) in <top (required)>'
     # ./spec/rails_helper.rb:79:in `block (2 levels) in <top (required)>'
     # ./spec/spec_helper.rb:108:in `block (2 levels) in <top (required)>'

  2) Payment Validation #amount_is_valid not charge more than event balance
     Failure/Error: if amount.to_i > payable.balance.to_i
     
     NoMethodError:
       undefined method `balance' for nil:NilClass
     # ./app/models/payment.rb:9:in `amount_is_valid'
     # ./spec/models/payment_spec.rb:39:in `block (4 levels) in <top (required)>'
     # ./spec/rails_helper.rb:80:in `block (3 levels) in <top (required)>'
     # ./spec/rails_helper.rb:79:in `block (2 levels) in <top (required)>'
     # ./spec/spec_helper.rb:108:in `block (2 levels) in <top (required)>'

Top 2 slowest examples (0.29972 seconds, 71.6% of total time):
  Payment Association should belong to payable required: true
    0.28796 seconds ./spec/models/payment_spec.rb:18
  Payment Validation #amount_is_valid not charge more than event balance
    0.01176 seconds ./spec/models/payment_spec.rb:32

Finished in 0.4186 seconds (files took 4.31 seconds to load)
2 examples, 2 failures

Failed examples:

rspec ./spec/models/payment_spec.rb:18 # Payment Association should belong to payable required: true
rspec ./spec/models/payment_spec.rb:32 # Payment Validation #amount_is_valid not charge more than event balance


Update

Passing specs based on Schwern's feedback. Still using a custom validation for amount, because balance is a field on the associated payable, not the payment (couldn't find a way to access an associated model from inside a built-in validation helper)

# payment.rb

class Payment < ApplicationRecord

  belongs_to :payable, polymorphic: true

  validates :payable, presence: true 
  validate :amount_is_valid 
  
  def amount_is_valid
    if amount > payable.balance
      errors.add(:amount, "can't be greater than balance")
    end
  end

end

# spec/models/payment_spec.rb

require 'rails_helper'

RSpec.describe Payment, type: :model do
  
  let(:event) { FactoryBot.create(:event, event_type: 'test', total: 10000, balance: 10000) }
  let(:user) {FactoryBot.create(:user)}
  let(:payment) { 
    FactoryBot.build(:payment, 
      amount: 300,
      method: 'cash', 
      payer_id: user.id,
      payable: event, 
      status: 1,
    )
  }

  describe '#payable' do
    it 'is an Event' do
      expect(payment.payable).to be_a(Event)
    end
  end
  
  describe '#amount' do 
    context 'amount is higher than balance' do 
      before {
        payment.amount = payment.payable.balance + 1
      }
      it 'is invalid' do 
        payment.validate
        expect(payment.errors[:amount]).to include("can't be greater than balance")
      end
    end
  end

end

Upvotes: 3

Views: 1756

Answers (1)

Schwern
Schwern

Reputation: 164679

Your first test is not failing where you think it is. It's failing on the next line, is_expected.to belong_to(:payable).

You're setting payment, but you're testing the implicitly defined subject which will be Payment.new.

is_expected.to belong_to(:payable)

Is equivalent to...

expect(subject).to belong_to(:payable)

And since you have no defined subject this is...

expect(Payment.new).to belong_to(:payable)

Payment.new does not have payable defined and so the amount_is_valid validation errors.

To fix this, test payment directly. And I would suggest staying away from subject while you're learning RSpec. And you should not have to set payment.event, it's already set in the factory.

describe 'Association' do
  expect(payment).to belong_to(:payable)
end

But I'm not aware of a belong_to matcher. You should not be directly checking implementation, but rather its behavior. The behavior you want is for payment.payable to return a Payable.

describe '#payable' do
  it 'is a Payable' do
    expect(payment.payable).to be_a(Payable)
  end
end

The second failure is because you have incorrectly initialized your Payment. You're passing in payable_id: event.id but that does not set payable_type. Without payable_type it doesn't know what class the ID is for.

Instead, pass the objects in directly.

let!(:payment) { 
  FactoryBot.build(:payment, 
    amount: 300,
    method: 'cash', 
    payer: user,
    payable: event, 
    status: 1,
  )
}

Some more general cleanups...

  • let! will always run the block whether it's used or not. Unless you specifically need that, use let and the blocks will run as needed.
  • You expect payable to exist, so validate the presence of payable.
  • Use the built in numericality validator on amount.
class Payment < ApplicationRecord

  belongs_to :payable, polymorphic: true
  validates :payable, presence: true

  validates :amount, numericality: {
    less_than_or_equal_to: balance,
    message: "must be less than or equal to the balance of #{balance}"
  }
end
require 'rails_helper'

RSpec.describe Payment, type: :model do
  let(:event) {
    create(:event, event_type: 'test', total: 10000, balance: 10000)
  }
  let(:user) { create(:user) }
  let(:payment) {
    build(:payment, 
      amount: 300,
      method: 'cash', 
      payer: user,
      payable: event,
      status: 1
    )
  }

  # It's useful to organize tests by method.
  describe '#payable' do
    it 'is a Payable' do
      expect(payment.payable).to be_a(Payable)
    end
  end

  describe '#amount' do
    # Contexts also help organize and name your tests.
    context 'when the amount is higher than the payable balance' do
      # This code will run before each example.
      before {
        # Rather than hard coding numbers, make your tests relative.
        # If event.balance changes the test will still work.
        payment.amount = payment.payable.balance + 1
      }
    
      it 'is invalid' do 
        expect(payment.valid?).to be false
        expect(payment.errors[:amount]).to include("must be less than or equal to")
      end
    end
  end
end

Upvotes: 4

Related Questions