thoughtbot / shoulda-matchers

Simple one-liner tests for common Rails functionality
https://matchers.shoulda.io
MIT License
3.51k stars 912 forks source link

Polymorphic association and uniqueness #1416

Closed dawidof closed 3 years ago

dawidof commented 3 years ago

I've got model

class Expiration < ApplicationRecord
  belongs_to :provider
  belongs_to :resourceable, polymorphic: true
  validates :resourceable, :provider_id, :expires_at, presence: true
  validates :resourceable_id, uniqueness: { scope:   %i[resourceable_type provider_id expires_at],
                                            message: 'is already taken' }
end

and test

require 'rails_helper'

RSpec.describe Expiration, type: :model do
  it { is_expected.to belong_to(:provider) }
  it { is_expected.to belong_to(:resourceable) }
  it { is_expected.to have_db_column(:resourceable_id).of_type(:integer) }
  it { is_expected.to have_db_column(:resourceable_type).of_type(:string) }

  it do
    expect(subject).to validate_uniqueness_of(:resourceable_id).scoped_to(%i[resourceable_type provider_id expires_at])
                                                               .with_message('is already taken')
  end
end

Fails with error

Failure/Error:
       expect(subject).to validate_uniqueness_of(:resourceable_id).scoped_to(%i[resourceable_type provider_id expires_at])
                                                                  .with_message('is already taken')

     Shoulda::Matchers::ActiveRecord::ValidateUniquenessOfMatcher::ExistingRecordInvalid:
       validate_uniqueness_of works by matching a new record against an
       existing record. If there is no existing record, it will create one
       using the record you provide.

       While doing this, the following error was raised:

         PG::NotNullViolation: ERROR:  null value in column "provider_id" of relation "expirations" violates not-null constraint
         DETAIL:  Failing row contains (1, null, null, null, null, 2021-02-16 16:27:40.755815, 2021-02-16 16:27:40.755815).

       The best way to fix this is to provide the matcher with a record where
       any required attributes are filled in with valid values beforehand.
     # /Users/dawidof/.rvm/gems/ruby-2.7.1/gems/shoulda-matchers-4.4.1/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb:544:in `rescue in create_existing_record'
     # /Users/dawidof/.rvm/gems/ruby-2.7.1/gems/shoulda-matchers-4.4.1/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb:539:in `create_existing_record'
     # /Users/dawidof/.rvm/gems/ruby-2.7.1/gems/shoulda-matchers-4.4.1/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb:524:in `find_or_create_existing_record'
     # /Users/dawidof/.rvm/gems/ruby-2.7.1/gems/shoulda-matchers-4.4.1/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb:514:in `existing_record'
     # /Users/dawidof/.rvm/gems/ruby-2.7.1/gems/shoulda-matchers-4.4.1/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb:909:in `existing_value_read'
     # /Users/dawidof/.rvm/gems/ruby-2.7.1/gems/shoulda-matchers-4.4.1/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb:673:in `matches_uniqueness_without_scopes?'
     # /Users/dawidof/.rvm/gems/ruby-2.7.1/gems/shoulda-matchers-4.4.1/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb:330:in `matches?'
     # ./spec/models/expiration_spec.rb:12:in `block (2 levels) in <top (required)>'
     # ./spec/rails_helper.rb:106:in `block (3 levels) in <top (required)>'
     # /Users/dawidof/.rvm/gems/ruby-2.7.1/gems/database_cleaner-core-2.0.0/lib/database_cleaner/strategy.rb:30:in `cleaning'
     # /Users/dawidof/.rvm/gems/ruby-2.7.1/gems/database_cleaner-core-2.0.0/lib/database_cleaner/cleaners.rb:30:in `block (2 levels) in cleaning'
     # /Users/dawidof/.rvm/gems/ruby-2.7.1/gems/database_cleaner-core-2.0.0/lib/database_cleaner/cleaners.rb:31:in `cleaning'
     # ./spec/rails_helper.rb:105:in `block (2 levels) in <top (required)>'
     # /Users/dawidof/.rvm/gems/ruby-2.7.1/gems/webmock-3.11.0/lib/webmock/rspec.rb:37:in `block (2 levels) in <top (required)>'
     # ------------------
     # --- Caused by: ---
     # PG::NotNullViolation:
     #   ERROR:  null value in column "provider_id" of relation "expirations" violates not-null constraint
     #   DETAIL:  Failing row contains (1, null, null, null, null, 2021-02-16 16:27:40.755815, 2021-02-16 16:27:40.755815).
     #   /Users/dawidof/.rvm/gems/ruby-2.7.1/gems/shoulda-matchers-4.4.1/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb:541:in `block in create_existing_record'
mcmire commented 3 years ago

This is a common issue with the validate_uniqueness_of matcher. If you don't provide a subject in a top-level describe block, then RSpec will assume that you want an instance of the class you're testing whenever you use subject. validate_uniqueness_of then tries to save this instance (because it needs an existing version of Expiration to test uniqueness against). Expiration.new.save! isn't going to work since there are columns that need to be filled in first, so the gem throws up its hands.

We probably need to go ahead and mention this in the error message, but there are some more words about this and a solution in the documentation here: http://matchers.shoulda.io/docs/v4.5.1/Shoulda/Matchers/ActiveRecord.html#validate_uniqueness_of-instance_method. See if that helps and if you have more questions, let me know!

dawidof commented 3 years ago

@mcmire Thanks for explanation, it works