diff --git a/lib/shoulda/matchers.rb b/lib/shoulda/matchers.rb index 72ee0f687..b9acdbef0 100644 --- a/lib/shoulda/matchers.rb +++ b/lib/shoulda/matchers.rb @@ -3,6 +3,7 @@ require 'shoulda/matchers/error' require 'shoulda/matchers/independent' require 'shoulda/matchers/integrations' +require 'shoulda/matchers/matcher_collection' require 'shoulda/matchers/matcher_context' require 'shoulda/matchers/rails_shim' require 'shoulda/matchers/util' diff --git a/lib/shoulda/matchers/active_model/allow_value_matcher.rb b/lib/shoulda/matchers/active_model/allow_value_matcher.rb index defdc241f..178f2bc0e 100644 --- a/lib/shoulda/matchers/active_model/allow_value_matcher.rb +++ b/lib/shoulda/matchers/active_model/allow_value_matcher.rb @@ -458,7 +458,7 @@ def failure_message message << '.' else message << " producing these validation errors:\n\n" - message << validator.all_formatted_validation_error_messages + message << validator.formatted_validation_error_messages end end diff --git a/lib/shoulda/matchers/active_model/helpers.rb b/lib/shoulda/matchers/active_model/helpers.rb index bf23a71f2..31aab6735 100644 --- a/lib/shoulda/matchers/active_model/helpers.rb +++ b/lib/shoulda/matchers/active_model/helpers.rb @@ -7,8 +7,10 @@ def pretty_error_messages(object) format_validation_errors(object.errors) end - def format_validation_errors(errors) + def format_validation_errors(errors, attr = nil) list_items = errors.to_hash.keys.map do |attribute| + next if attr && attr.to_sym != attribute.to_sym + messages = errors[attribute] "* #{attribute}: #{messages}" end diff --git a/lib/shoulda/matchers/active_model/validate_absence_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_absence_of_matcher.rb index 3f6f66c91..622cdd6c0 100644 --- a/lib/shoulda/matchers/active_model/validate_absence_of_matcher.rb +++ b/lib/shoulda/matchers/active_model/validate_absence_of_matcher.rb @@ -70,10 +70,33 @@ module ActiveModel # with_message("there shall be peace on Earth") # end # + # #### Multiple attributes + # + # You can pass multiple attributes to assert that each one has the + # validation. Any qualifier chained on the matcher is applied to + # every attribute uniformly. + # + # class Robot + # include ActiveModel::Model + # attr_accessor :arms, :legs + # + # validates_absence_of :arms, :legs + # end + # + # # RSpec + # RSpec.describe Robot, type: :model do + # it { should validate_absence_of(:arms, :legs) } + # end + # + # # Minitest (Shoulda) + # class RobotTest < ActiveSupport::TestCase + # should validate_absence_of(:arms, :legs) + # end + # # @return [ValidateAbsenceOfMatcher} # - def validate_absence_of(attr) - ValidateAbsenceOfMatcher.new(attr) + def validate_absence_of(*attrs) + MatcherCollection.build(attrs) { |attr| ValidateAbsenceOfMatcher.new(attr) } end # @private diff --git a/lib/shoulda/matchers/active_model/validate_acceptance_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_acceptance_of_matcher.rb index 7c2734a91..7864c83f2 100644 --- a/lib/shoulda/matchers/active_model/validate_acceptance_of_matcher.rb +++ b/lib/shoulda/matchers/active_model/validate_acceptance_of_matcher.rb @@ -73,10 +73,33 @@ module ActiveModel # with_message('You must accept the terms of service') # end # + # #### Multiple attributes + # + # You can pass multiple attributes to assert that each one has the + # validation. Any qualifier chained on the matcher is applied to + # every attribute uniformly. + # + # class User + # include ActiveModel::Model + # attr_accessor :terms, :privacy_policy + # + # validates_acceptance_of :terms, :privacy_policy + # end + # + # # RSpec + # RSpec.describe User, type: :model do + # it { should validate_acceptance_of(:terms, :privacy_policy) } + # end + # + # # Minitest (Shoulda) + # class UserTest < ActiveSupport::TestCase + # should validate_acceptance_of(:terms, :privacy_policy) + # end + # # @return [ValidateAcceptanceOfMatcher] # - def validate_acceptance_of(attr) - ValidateAcceptanceOfMatcher.new(attr) + def validate_acceptance_of(*attrs) + MatcherCollection.build(attrs) { |attr| ValidateAcceptanceOfMatcher.new(attr) } end # @private diff --git a/lib/shoulda/matchers/active_model/validate_comparison_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_comparison_of_matcher.rb index da02a70ba..cbc934835 100644 --- a/lib/shoulda/matchers/active_model/validate_comparison_of_matcher.rb +++ b/lib/shoulda/matchers/active_model/validate_comparison_of_matcher.rb @@ -272,10 +272,33 @@ module ActiveModel # should validate_comparison_of(:age).is_greater_than(0).allow_nil # end # + # #### Multiple attributes + # + # You can pass multiple attributes to assert that each one has the + # validation. Any qualifier chained on the matcher is applied to + # every attribute uniformly. + # + # class Item + # include ActiveModel::Model + # attr_accessor :width, :height + # + # validates_comparison_of :width, :height, greater_than: 0 + # end + # + # # RSpec + # RSpec.describe Item, type: :model do + # it { should validate_comparison_of(:width, :height).is_greater_than(0) } + # end + # + # # Minitest (Shoulda) + # class ItemTest < ActiveSupport::TestCase + # should validate_comparison_of(:width, :height).is_greater_than(0) + # end + # # @return [ValidateComparisonOfMatcher] # - def validate_comparison_of(attr) - ValidateComparisonOfMatcher.new(attr) + def validate_comparison_of(*attrs) + MatcherCollection.build(attrs) { |attr| ValidateComparisonOfMatcher.new(attr) } end # @private @@ -378,6 +401,13 @@ def failure_message_when_negated end end + def failure_reason + raw_submatcher_failure_reason_for( + first_submatcher_that_fails_to_match, + :failure_message, + ) + end + def given_numeric_column? attribute_is_active_record_column? && [:integer, :float, :decimal].include?(column_type) @@ -485,21 +515,26 @@ def build_submatcher_failure_message_for( submatcher, failure_message_method ) + Shoulda::Matchers.word_wrap( + raw_submatcher_failure_reason_for(submatcher, failure_message_method), + indent: 2, + ) + end + + def raw_submatcher_failure_reason_for(submatcher, failure_message_method) failure_message = submatcher.public_send(failure_message_method) submatcher_description = submatcher.simple_description. sub(/\bvalidate that\b/, 'validates'). sub(/\bdisallow\b/, 'disallows'). sub(/\ballow\b/, 'allows') - submatcher_message = - if number_of_submatchers_for_failure_message > 1 - "In checking that #{model.name} #{submatcher_description}, " + - failure_message[0].downcase + - failure_message[1..] - else - failure_message - end - Shoulda::Matchers.word_wrap(submatcher_message, indent: 2) + if number_of_submatchers_for_failure_message > 1 + "In checking that #{model.name} #{submatcher_description}, " + + failure_message[0].downcase + + failure_message[1..] + else + failure_message + end end def comparison_descriptions diff --git a/lib/shoulda/matchers/active_model/validate_confirmation_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_confirmation_of_matcher.rb index 0a822737a..4bdd08f95 100644 --- a/lib/shoulda/matchers/active_model/validate_confirmation_of_matcher.rb +++ b/lib/shoulda/matchers/active_model/validate_confirmation_of_matcher.rb @@ -70,10 +70,33 @@ module ActiveModel # with_message('Please re-enter your password') # end # + # #### Multiple attributes + # + # You can pass multiple attributes to assert that each one has the + # validation. Any qualifier chained on the matcher is applied to + # every attribute uniformly. + # + # class User + # include ActiveModel::Model + # attr_accessor :password, :password_confirmation, :email, :email_confirmation + # + # validates_confirmation_of :password, :email + # end + # + # # RSpec + # RSpec.describe User, type: :model do + # it { should validate_confirmation_of(:password, :email) } + # end + # + # # Minitest (Shoulda) + # class UserTest < ActiveSupport::TestCase + # should validate_confirmation_of(:password, :email) + # end + # # @return [ValidateConfirmationOfMatcher] # - def validate_confirmation_of(attr) - ValidateConfirmationOfMatcher.new(attr) + def validate_confirmation_of(*attrs) + MatcherCollection.build(attrs) { |attr| ValidateConfirmationOfMatcher.new(attr) } end # @private diff --git a/lib/shoulda/matchers/active_model/validate_exclusion_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_exclusion_of_matcher.rb index 2d64479f6..15afcfeb5 100644 --- a/lib/shoulda/matchers/active_model/validate_exclusion_of_matcher.rb +++ b/lib/shoulda/matchers/active_model/validate_exclusion_of_matcher.rb @@ -112,10 +112,33 @@ module ActiveModel # with_message('You chose a puny weapon') # end # + # #### Multiple attributes + # + # You can pass multiple attributes to assert that each one has the + # validation. Any qualifier chained on the matcher is applied to + # every attribute uniformly. + # + # class Article + # include ActiveModel::Model + # attr_accessor :slug, :handle + # + # validates_exclusion_of :slug, :handle, in: %w[admin root] + # end + # + # # RSpec + # RSpec.describe Article, type: :model do + # it { should validate_exclusion_of(:slug, :handle).in_array(%w[admin root]) } + # end + # + # # Minitest (Shoulda) + # class ArticleTest < ActiveSupport::TestCase + # should validate_exclusion_of(:slug, :handle).in_array(%w[admin root]) + # end + # # @return [ValidateExclusionOfMatcher] # - def validate_exclusion_of(attr) - ValidateExclusionOfMatcher.new(attr) + def validate_exclusion_of(*attrs) + MatcherCollection.build(attrs) { |attr| ValidateExclusionOfMatcher.new(attr) } end # @private diff --git a/lib/shoulda/matchers/active_model/validate_inclusion_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_inclusion_of_matcher.rb index 19424ef8b..f924a7948 100644 --- a/lib/shoulda/matchers/active_model/validate_inclusion_of_matcher.rb +++ b/lib/shoulda/matchers/active_model/validate_inclusion_of_matcher.rb @@ -263,10 +263,33 @@ module ActiveModel # allow_blank # end # + # #### Multiple attributes + # + # You can pass multiple attributes to assert that each one has the + # validation. Any qualifier chained on the matcher is applied to + # every attribute uniformly. + # + # class Article + # include ActiveModel::Model + # attr_accessor :status, :category + # + # validates_inclusion_of :status, :category, in: %w[draft published] + # end + # + # # RSpec + # RSpec.describe Article, type: :model do + # it { should validate_inclusion_of(:status, :category).in_array(%w[draft published]) } + # end + # + # # Minitest (Shoulda) + # class ArticleTest < ActiveSupport::TestCase + # should validate_inclusion_of(:status, :category).in_array(%w[draft published]) + # end + # # @return [ValidateInclusionOfMatcher] # - def validate_inclusion_of(attr) - ValidateInclusionOfMatcher.new(attr) + def validate_inclusion_of(*attrs) + MatcherCollection.build(attrs) { |attr| ValidateInclusionOfMatcher.new(attr) } end # @private diff --git a/lib/shoulda/matchers/active_model/validate_length_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_length_of_matcher.rb index 46bc9e458..5d4379496 100644 --- a/lib/shoulda/matchers/active_model/validate_length_of_matcher.rb +++ b/lib/shoulda/matchers/active_model/validate_length_of_matcher.rb @@ -240,7 +240,6 @@ module ActiveModel # should validate_length_of(:bio).is_at_least(15).allow_nil # end # - # @return [ValidateLengthOfMatcher] # # ##### allow_blank # @@ -286,8 +285,32 @@ module ActiveModel # should validate_length_of(:arr).as_array.is_at_least(15) # end # - def validate_length_of(attr) - ValidateLengthOfMatcher.new(attr) + # #### Multiple attributes + # + # You can pass multiple attributes to assert that each one has the + # validation. Any qualifier chained on the matcher is applied to + # every attribute uniformly. + # + # class User + # include ActiveModel::Model + # attr_accessor :first_name, :last_name + # + # validates_length_of :first_name, :last_name, minimum: 2 + # end + # + # # RSpec + # RSpec.describe User, type: :model do + # it { should validate_length_of(:first_name, :last_name).is_at_least(2) } + # end + # + # # Minitest (Shoulda) + # class UserTest < ActiveSupport::TestCase + # should validate_length_of(:first_name, :last_name).is_at_least(2) + # end + # + # @return [ValidateLengthOfMatcher] + def validate_length_of(*attrs) + MatcherCollection.build(attrs) { |attr| ValidateLengthOfMatcher.new(attr) } end # @private diff --git a/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb index 20604af40..c03f619e9 100644 --- a/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb +++ b/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb @@ -350,10 +350,33 @@ module ActiveModel # should validate_numericality_of(:age).allow_nil # end # + # #### Multiple attributes + # + # You can pass multiple attributes to assert that each one has the + # validation. Any qualifier chained on the matcher is applied to + # every attribute uniformly. + # + # class Item + # include ActiveModel::Model + # attr_accessor :price, :quantity + # + # validates_numericality_of :price, :quantity + # end + # + # # RSpec + # RSpec.describe Item, type: :model do + # it { should validate_numericality_of(:price, :quantity) } + # end + # + # # Minitest (Shoulda) + # class ItemTest < ActiveSupport::TestCase + # should validate_numericality_of(:price, :quantity) + # end + # # @return [ValidateNumericalityOfMatcher] # - def validate_numericality_of(attr) - ValidateNumericalityOfMatcher.new(attr) + def validate_numericality_of(*attrs) + MatcherCollection.build(attrs) { |attr| ValidateNumericalityOfMatcher.new(attr) } end # @private @@ -482,6 +505,13 @@ def failure_message_when_negated end end + def failure_reason + raw_submatcher_failure_reason_for( + first_submatcher_that_fails_to_match, + :failure_message, + ) + end + def given_numeric_column? attribute_is_active_record_column? && [:integer, :float, :decimal].include?(column_type) @@ -620,21 +650,26 @@ def build_submatcher_failure_message_for( submatcher, failure_message_method ) + Shoulda::Matchers.word_wrap( + raw_submatcher_failure_reason_for(submatcher, failure_message_method), + indent: 2, + ) + end + + def raw_submatcher_failure_reason_for(submatcher, failure_message_method) failure_message = submatcher.public_send(failure_message_method) submatcher_description = submatcher.simple_description. sub(/\bvalidate that\b/, 'validates'). sub(/\bdisallow\b/, 'disallows'). sub(/\ballow\b/, 'allows') - submatcher_message = - if number_of_submatchers_for_failure_message > 1 - "In checking that #{model.name} #{submatcher_description}, " + - failure_message[0].downcase + - failure_message[1..] - else - failure_message - end - Shoulda::Matchers.word_wrap(submatcher_message, indent: 2) + if number_of_submatchers_for_failure_message > 1 + "In checking that #{model.name} #{submatcher_description}, " + + failure_message[0].downcase + + failure_message[1..] + else + failure_message + end end def full_allowed_type diff --git a/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb b/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb index 9b047e7a8..9cbcaf7f5 100644 --- a/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb +++ b/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb @@ -145,10 +145,34 @@ module ActiveModel # with_message('Robot has no legs') # end # + # #### Multiple attributes + # + # You can pass multiple attributes to assert that each one has the + # validation. Any qualifier chained on the matcher is applied to + # every attribute uniformly. + # + # class Robot + # include ActiveModel::Model + # attr_accessor :arms, :legs + # + # validates_presence_of :arms, :legs + # end + # + # # RSpec + # RSpec.describe Robot, type: :model do + # it { should validate_presence_of(:arms, :legs) } + # end + # + # # Minitest (Shoulda) + # class RobotTest < ActiveSupport::TestCase + # should validate_presence_of(:arms, :legs) + # end + # # @return [ValidatePresenceOfMatcher] # - def validate_presence_of(attr) - ValidatePresenceOfMatcher.new(attr) + + def validate_presence_of(*attrs) + MatcherCollection.build(attrs) { |attr| ValidatePresenceOfMatcher.new(attr) } end # @private @@ -206,27 +230,29 @@ def simple_description "validate that :#{@attribute} cannot be empty/falsy" end - def failure_message - message = super - + def failure_reason + reason = super if should_add_footnote_about_belongs_to? - message << "\n\n" - message << Shoulda::Matchers.word_wrap(<<-MESSAGE.strip, indent: 2) -You're getting this error because #{reason_for_existing_presence_validation}. -*This* presence validation doesn't use "can't be blank", the usual validation -message, but "must exist" instead. - -With that said, did you know that the `belong_to` matcher can test this -validation for you? Instead of using `validate_presence_of`, try -#{suggestions_for_belongs_to} - MESSAGE + "#{reason}\n\n#{belongs_to_footnote}" + else + reason end - - message end private + def belongs_to_footnote + <<~MESSAGE.strip + You're getting this error because #{reason_for_existing_presence_validation}. + *This* presence validation doesn't use "can't be blank", the usual validation + message, but "must exist" instead. + + With that said, did you know that the `belong_to` matcher can test this + validation for you? Instead of using `validate_presence_of`, try + #{suggestions_for_belongs_to} + MESSAGE + end + def secure_password_being_validated? Shoulda::Matchers::RailsShim.digestible_attributes_in(@subject). include?(@attribute) diff --git a/lib/shoulda/matchers/active_model/validation_matcher.rb b/lib/shoulda/matchers/active_model/validation_matcher.rb index 0a83c8c59..bd7ee76b6 100644 --- a/lib/shoulda/matchers/active_model/validation_matcher.rb +++ b/lib/shoulda/matchers/active_model/validation_matcher.rb @@ -86,6 +86,10 @@ def failure_message_when_negated end end + def failure_reason + last_submatcher_run.try(:failure_message) + end + protected attr_reader :attribute, :context, :subject, :last_submatcher_run @@ -150,14 +154,6 @@ def overall_failure_message_when_negated ) end - def failure_reason - last_submatcher_run.try(:failure_message) - end - - def failure_reason_when_negated - last_submatcher_run.try(:failure_message_when_negated) - end - def build_allow_or_disallow_value_matcher(args) matcher_class = args.fetch(:matcher_class) value = args.fetch(:value) diff --git a/lib/shoulda/matchers/active_model/validator.rb b/lib/shoulda/matchers/active_model/validator.rb index 70320082e..c3a8fce38 100644 --- a/lib/shoulda/matchers/active_model/validator.rb +++ b/lib/shoulda/matchers/active_model/validator.rb @@ -45,6 +45,10 @@ def validation_exception_message validation_result[:validation_exception_message] end + def formatted_validation_error_messages + format_validation_errors(all_validation_errors, attribute) + end + protected attr_reader :attribute, :context, :record diff --git a/lib/shoulda/matchers/active_record/have_attached_matcher.rb b/lib/shoulda/matchers/active_record/have_attached_matcher.rb index f18e31062..381f2cb9d 100644 --- a/lib/shoulda/matchers/active_record/have_attached_matcher.rb +++ b/lib/shoulda/matchers/active_record/have_attached_matcher.rb @@ -88,10 +88,31 @@ module ActiveRecord # should have_one_attached(:avatar).strict_loading # end # + # #### Multiple attributes + # + # You can pass multiple attributes to assert that each one has the + # validation. Any qualifier chained on the matcher is applied to + # every attribute uniformly. + # + # class User < ActiveRecord::Base + # has_one_attached :avatar + # has_one_attached :cover + # end + # + # # RSpec + # RSpec.describe User, type: :model do + # it { should have_one_attached(:avatar, :cover) } + # end + # + # # Minitest (Shoulda) + # class UserTest < ActiveSupport::TestCase + # should have_one_attached(:avatar, :cover) + # end + # # @return [HaveAttachedMatcher] # - def have_one_attached(name) - HaveAttachedMatcher.new(:one, name) + def have_one_attached(*names) + MatcherCollection.build(names) { |name| HaveAttachedMatcher.new(:one, name) } end # The `have_many_attached` matcher tests usage of the @@ -140,6 +161,10 @@ def failure_message MESSAGE end + def failure_reason + @failure + end + def failure_message_when_negated <<-MESSAGE Did not expect #{expectation}, but it does. diff --git a/lib/shoulda/matchers/active_record/have_readonly_attribute_matcher.rb b/lib/shoulda/matchers/active_record/have_readonly_attribute_matcher.rb index 43e6debc3..059c13ae6 100644 --- a/lib/shoulda/matchers/active_record/have_readonly_attribute_matcher.rb +++ b/lib/shoulda/matchers/active_record/have_readonly_attribute_matcher.rb @@ -18,10 +18,30 @@ module ActiveRecord # should have_readonly_attribute(:password) # end # + # #### Multiple attributes + # + # You can pass multiple attributes to assert that each one has the + # validation. Any qualifier chained on the matcher is applied to + # every attribute uniformly. + # + # class User < ActiveRecord::Base + # attr_readonly :name, :email + # end + # + # # RSpec + # RSpec.describe User, type: :model do + # it { should have_readonly_attribute(:name, :email) } + # end + # + # # Minitest (Shoulda) + # class UserTest < ActiveSupport::TestCase + # should have_readonly_attribute(:name, :email) + # end + # # @return [HaveReadonlyAttributeMatcher] # - def have_readonly_attribute(value) - HaveReadonlyAttributeMatcher.new(value) + def have_readonly_attribute(*values) + MatcherCollection.build(values) { |value| HaveReadonlyAttributeMatcher.new(value) } end # @private @@ -32,6 +52,10 @@ def initialize(attribute) attr_reader :failure_message, :failure_message_when_negated + def failure_reason + @failure_message + end + def matches?(subject) @subject = subject if readonly_attributes.include?(@attribute) diff --git a/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb b/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb index 2c30e38c5..2df1d9079 100644 --- a/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb +++ b/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb @@ -304,7 +304,6 @@ def initialize(attribute) } @existing_record_created = false @failure_reason = nil - @failure_reason_when_negated = nil @attribute_setters = { existing_record: AttributeSetters.new, new_record: AttributeSetters.new, @@ -407,10 +406,6 @@ def failure_reason @failure_reason || super end - def failure_reason_when_negated - @failure_reason_when_negated || super - end - def build_allow_or_disallow_value_matcher(args) super.tap do |matcher| matcher.failure_message_preface = method(:failure_message_preface) diff --git a/lib/shoulda/matchers/matcher_collection.rb b/lib/shoulda/matchers/matcher_collection.rb new file mode 100644 index 000000000..a7cb03b54 --- /dev/null +++ b/lib/shoulda/matchers/matcher_collection.rb @@ -0,0 +1,99 @@ +module Shoulda + module Matchers + # @private + class MatcherCollection + def self.build(attrs, &block) + new(attrs.map(&block)) + end + + def initialize(matchers) + @matchers = matchers + @failed_matchers = [] + end + + def description + matchers.map(&:description).join(' and ') + end + + def matches?(subject) + @subject = subject + @failed_matchers = matchers.reject do |matcher| + matcher.matches?(fresh_subject_for(subject)) + end + @failed_matchers.empty? + end + + def does_not_match?(subject) + @subject = subject + @failed_matchers = matchers.reject do |matcher| + fresh_subject = fresh_subject_for(subject) + if matcher.respond_to?(:does_not_match?) + matcher.does_not_match?(fresh_subject) + else + !matcher.matches?(fresh_subject) + end + end + @failed_matchers.empty? + end + + def failure_message + if matchers.one? + matchers.first.failure_message + else + build_failure_message('to') + end + end + + def failure_message_when_negated + if matchers.one? + matchers.first.failure_message_when_negated + else + build_failure_message('not to') + end + end + + def method_missing(method, *args, &block) + if all_matchers_respond_to?(method) + matchers.each { |matcher| matcher.send(method, *args, &block) } + self + else + super + end + end + + def respond_to_missing?(method, include_private = false) + all_matchers_respond_to?(method) || super + end + + private + + attr_reader :matchers + + def fresh_subject_for(subject) + matchers.one? ? subject : subject.dup + end + + def build_failure_message(direction) + header = Shoulda::Matchers.word_wrap( + "Expected #{@subject.class.name} #{direction} "\ + "#{failed_description}, but this could not be proved.", + ) + + reasons = @failed_matchers.filter_map do |matcher| + reason = matcher.failure_reason + Shoulda::Matchers.word_wrap(reason, indent: 2) if reason.present? + end + + ([header] + reasons).join("\n") + end + + def failed_description + @failed_matchers.map(&:description).join(' and ') + end + + def all_matchers_respond_to?(method) + matchers.all? { |matcher| matcher.respond_to?(method) } + end + end + end +end diff --git a/spec/unit/shoulda/matchers/active_model/validate_absence_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/validate_absence_of_matcher_spec.rb index 7a46465a1..05a57ed23 100644 --- a/spec/unit/shoulda/matchers/active_model/validate_absence_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/validate_absence_of_matcher_spec.rb @@ -18,6 +18,54 @@ def self.available_column_types end context 'a model with an absence validation' do + context 'passing multiple attributes' do + it 'accepts when every attribute has the validation' do + model = define_model 'Example', attr1: :string, attr2: :string do + validates_absence_of :attr1, :attr2 + end + + expect(model.new).to validate_absence_of(:attr1, :attr2) + end + + it 'rejects when one attribute has the validation and one does not' do + model = define_model 'Example', attr1: :string, attr2: :string do + validates_absence_of :attr1 + end + + assertion = lambda do + expect(model.new).to validate_absence_of(:attr1, :attr2) + end + + message = <<-MESSAGE +Expected Example to validate that :attr2 is empty/falsy, but this could +not be proved. + After setting :attr2 to ‹"an arbitrary value"›, the matcher expected + the Example to be invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'rejects when no attribute has the validation' do + model = define_model 'Example', attr1: :string, attr2: :string + + assertion = lambda do + expect(model.new).to validate_absence_of(:attr1, :attr2) + end + + message = <<-MESSAGE +Expected Example to validate that :attr1 is empty/falsy and validate +that :attr2 is empty/falsy, but this could not be proved. + After setting :attr1 to ‹"an arbitrary value"›, the matcher expected + the Example to be invalid, but it was valid instead. + After setting :attr2 to ‹"an arbitrary value"›, the matcher expected + the Example to be invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + it 'accepts' do expect(validating_absence_of(:attr)).to validate_absence_of(:attr) end diff --git a/spec/unit/shoulda/matchers/active_model/validate_acceptance_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/validate_acceptance_of_matcher_spec.rb index 5393ded9b..21ece6842 100644 --- a/spec/unit/shoulda/matchers/active_model/validate_acceptance_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/validate_acceptance_of_matcher_spec.rb @@ -2,6 +2,63 @@ describe Shoulda::Matchers::ActiveModel::ValidateAcceptanceOfMatcher, type: :model do context 'a model with an acceptance validation' do + context 'passing multiple attributes' do + it 'accepts when every attribute has the validation' do + model = define_active_model_class( + 'Example', + accessors: [:attr1, :attr2], + ) do + validates_acceptance_of :attr1, :attr2 + end + + expect(model.new).to validate_acceptance_of(:attr1, :attr2) + end + + it 'rejects when one attribute has the validation and one does not' do + model = define_active_model_class( + 'Example', + accessors: [:attr1, :attr2], + ) do + validates_acceptance_of :attr1 + end + + assertion = lambda do + expect(model.new).to validate_acceptance_of(:attr1, :attr2) + end + + message = <<-MESSAGE +Expected Example to validate that :attr2 has been set to "1", but this +could not be proved. + After setting :attr2 to ‹false›, the matcher expected the Example to + be invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'rejects when no attribute has the validation' do + model = define_active_model_class( + 'Example', + accessors: [:attr1, :attr2], + ) + + assertion = lambda do + expect(model.new).to validate_acceptance_of(:attr1, :attr2) + end + + message = <<-MESSAGE +Expected Example to validate that :attr1 has been set to "1" and +validate that :attr2 has been set to "1", but this could not be proved. + After setting :attr1 to ‹false›, the matcher expected the Example to + be invalid, but it was valid instead. + After setting :attr2 to ‹false›, the matcher expected the Example to + be invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + it 'accepts when the attributes match' do expect(record_validating_acceptance).to matcher end diff --git a/spec/unit/shoulda/matchers/active_model/validate_comparison_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/validate_comparison_of_matcher_spec.rb index 4691b8040..731100293 100644 --- a/spec/unit/shoulda/matchers/active_model/validate_comparison_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/validate_comparison_of_matcher_spec.rb @@ -1,6 +1,58 @@ require 'unit_spec_helper' describe Shoulda::Matchers::ActiveModel::ValidateComparisonOfMatcher, type: :model do + context 'passing multiple attributes' do + it 'accepts when every attribute has the validation' do + model = define_model 'Example', attr1: :integer, attr2: :integer do + validates_comparison_of :attr1, :attr2, greater_than: 18 + end + + expect(model.new). + to validate_comparison_of(:attr1, :attr2).is_greater_than(18) + end + + it 'rejects when one attribute has the validation and one does not' do + model = define_model 'Example', attr1: :integer, attr2: :integer do + validates_comparison_of :attr1, greater_than: 18, allow_nil: true + end + + assertion = lambda do + expect(model.new). + to validate_comparison_of(:attr1, :attr2).is_greater_than(18) + end + + message = <<-MESSAGE +Expected Example to validate that :attr2 looks like a value greater than +18, but this could not be proved. + After setting :attr2 to ‹18›, the matcher expected the Example to be + invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'rejects when no attribute has the validation' do + model = define_model 'Example', attr1: :integer, attr2: :integer + + assertion = lambda do + expect(model.new). + to validate_comparison_of(:attr1, :attr2).is_greater_than(18) + end + + message = <<-MESSAGE +Expected Example to validate that :attr1 looks like a value greater than +18 and validate that :attr2 looks like a value greater than 18, but this +could not be proved. + After setting :attr1 to ‹18›, the matcher expected the Example to be + invalid, but it was valid instead. + After setting :attr2 to ‹18›, the matcher expected the Example to be + invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + context 'with combinations of qualifiers together' do context 'when the qualifiers do not match the validation options' do specify 'such as validating greater_than_or_equal_to but testing that greater_than is validated' do diff --git a/spec/unit/shoulda/matchers/active_model/validate_confirmation_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/validate_confirmation_of_matcher_spec.rb index 10d60cacf..803e0801a 100644 --- a/spec/unit/shoulda/matchers/active_model/validate_confirmation_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/validate_confirmation_of_matcher_spec.rb @@ -13,6 +13,70 @@ end context 'when the model has a confirmation validation' do + context 'passing multiple attributes' do + it 'accepts when every attribute has the validation' do + model = define_model 'Example', + password: :string, + password_confirmation: :string, + email: :string, + email_confirmation: :string do + validates_confirmation_of :password, :email + end + + expect(model.new).to validate_confirmation_of(:password, :email) + end + + it 'rejects when one attribute has the validation and one does not' do + model = define_model 'Example', + password: :string, + password_confirmation: :string, + email: :string, + email_confirmation: :string do + validates_confirmation_of :password + end + + assertion = lambda do + expect(model.new).to validate_confirmation_of(:password, :email) + end + + message = <<-MESSAGE +Expected Example to validate that :email_confirmation matches :email, +but this could not be proved. + After setting :email_confirmation to ‹"some value"›, then setting + :email to ‹"different value"›, the matcher expected the Example to be + invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'rejects when no attribute has the validation' do + model = define_model 'Example', + password: :string, + password_confirmation: :string, + email: :string, + email_confirmation: :string + + assertion = lambda do + expect(model.new).to validate_confirmation_of(:password, :email) + end + + message = <<-MESSAGE +Expected Example to validate that :password_confirmation matches +:password and validate that :email_confirmation matches :email, but this +could not be proved. + After setting :password_confirmation to ‹"some value"›, then setting + :password to ‹"different value"›, the matcher expected the Example to + be invalid, but it was valid instead. + After setting :email_confirmation to ‹"some value"›, then setting + :email to ‹"different value"›, the matcher expected the Example to be + invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + it 'passes' do builder = builder_for_record_validating_confirmation expect(builder.record). diff --git a/spec/unit/shoulda/matchers/active_model/validate_exclusion_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/validate_exclusion_of_matcher_spec.rb index baa0f6fae..969f4b9e9 100644 --- a/spec/unit/shoulda/matchers/active_model/validate_exclusion_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/validate_exclusion_of_matcher_spec.rb @@ -2,6 +2,58 @@ describe Shoulda::Matchers::ActiveModel::ValidateExclusionOfMatcher, type: :model do context 'an attribute which must be excluded from a range' do + context 'passing multiple attributes' do + it 'accepts when every attribute has the validation' do + model = define_model 'Example', attr1: :integer, attr2: :integer do + validates_exclusion_of :attr1, :attr2, in: 2..5 + end + + expect(model.new). + to validate_exclusion_of(:attr1, :attr2).in_range(2..5) + end + + it 'rejects when one attribute has the validation and one does not' do + model = define_model 'Example', attr1: :integer, attr2: :integer do + validates_exclusion_of :attr1, in: 2..5 + end + + assertion = lambda do + expect(model.new). + to validate_exclusion_of(:attr1, :attr2).in_range(2..5) + end + + message = <<-MESSAGE +Expected Example to validate that :attr2 lies outside the range ‹2› to +‹5›, but this could not be proved. + After setting :attr2 to ‹2›, the matcher expected the Example to be + invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'rejects when no attribute has the validation' do + model = define_model 'Example', attr1: :integer, attr2: :integer + + assertion = lambda do + expect(model.new). + to validate_exclusion_of(:attr1, :attr2).in_range(2..5) + end + + message = <<-MESSAGE +Expected Example to validate that :attr1 lies outside the range ‹2› to +‹5› and validate that :attr2 lies outside the range ‹2› to ‹5›, but this +could not be proved. + After setting :attr1 to ‹2›, the matcher expected the Example to be + invalid, but it was valid instead. + After setting :attr2 to ‹2›, the matcher expected the Example to be + invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + it 'accepts ensuring the correct range' do expect(validating_exclusion(in: 2..5)). to validate_exclusion_of(:attr).in_range(2..5) diff --git a/spec/unit/shoulda/matchers/active_model/validate_inclusion_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/validate_inclusion_of_matcher_spec.rb index 27d5e9b1f..44e545841 100644 --- a/spec/unit/shoulda/matchers/active_model/validate_inclusion_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/validate_inclusion_of_matcher_spec.rb @@ -1,6 +1,58 @@ require 'unit_spec_helper' describe Shoulda::Matchers::ActiveModel::ValidateInclusionOfMatcher, type: :model do + context 'passing multiple attributes' do + it 'accepts when every attribute has the validation' do + model = define_model 'Example', attr1: :integer, attr2: :integer do + validates_inclusion_of :attr1, :attr2, in: [1, 2, 3] + end + + expect(model.new). + to validate_inclusion_of(:attr1, :attr2).in_array([1, 2, 3]) + end + + it 'rejects when one attribute has the validation and one does not' do + model = define_model 'Example', attr1: :integer, attr2: :integer do + validates_inclusion_of :attr1, in: [1, 2, 3], allow_nil: true + end + + assertion = lambda do + expect(model.new). + to validate_inclusion_of(:attr1, :attr2).in_array([1, 2, 3]) + end + + message = <<-MESSAGE +Expected Example to validate that :attr2 is either ‹1›, ‹2›, or ‹3›, but +this could not be proved. + After setting :attr2 to ‹123456789›, the matcher expected the Example + to be invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'rejects when no attribute has the validation' do + model = define_model 'Example', attr1: :integer, attr2: :integer + + assertion = lambda do + expect(model.new). + to validate_inclusion_of(:attr1, :attr2).in_array([1, 2, 3]) + end + + message = <<-MESSAGE +Expected Example to validate that :attr1 is either ‹1›, ‹2›, or ‹3› and +validate that :attr2 is either ‹1›, ‹2›, or ‹3›, but this could not be +proved. + After setting :attr1 to ‹123456789›, the matcher expected the Example + to be invalid, but it was valid instead. + After setting :attr2 to ‹123456789›, the matcher expected the Example + to be invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + shared_context 'for a generic attribute' do context 'against an integer attribute' do it_behaves_like 'it supports in_array', diff --git a/spec/unit/shoulda/matchers/active_model/validate_length_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/validate_length_of_matcher_spec.rb index 027696ee0..e0be4c0dd 100644 --- a/spec/unit/shoulda/matchers/active_model/validate_length_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/validate_length_of_matcher_spec.rb @@ -2,6 +2,55 @@ describe Shoulda::Matchers::ActiveModel::ValidateLengthOfMatcher, type: :model do context 'an attribute with a non-zero minimum length validation' do + context 'passing multiple attributes' do + it 'accepts when every attribute has the validation' do + model = define_model 'Example', attr1: :string, attr2: :string do + validates_length_of :attr1, :attr2, minimum: 4 + end + + expect(model.new).to validate_length_of(:attr1, :attr2).is_at_least(4) + end + + it 'rejects when one attribute has the validation and one does not' do + model = define_model 'Example', attr1: :string, attr2: :string do + validates_length_of :attr1, minimum: 4, allow_nil: true + end + + assertion = lambda do + expect(model.new).to validate_length_of(:attr1, :attr2).is_at_least(4) + end + + message = <<-MESSAGE +Expected Example to validate that the length of :attr2 is at least 4, +but this could not be proved. + After setting :attr2 to ‹"xxx"›, the matcher expected the Example to + be invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'rejects when no attribute has the validation' do + model = define_model 'Example', attr1: :string, attr2: :string + + assertion = lambda do + expect(model.new).to validate_length_of(:attr1, :attr2).is_at_least(4) + end + + message = <<-MESSAGE +Expected Example to validate that the length of :attr1 is at least 4 and +validate that the length of :attr2 is at least 4, but this could not be +proved. + After setting :attr1 to ‹"xxx"›, the matcher expected the Example to + be invalid, but it was valid instead. + After setting :attr2 to ‹"xxx"›, the matcher expected the Example to + be invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + it 'accepts ensuring the correct minimum length' do expect(validating_length(minimum: 4)). to validate_length_of(:attr).is_at_least(4) diff --git a/spec/unit/shoulda/matchers/active_model/validate_numericality_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/validate_numericality_of_matcher_spec.rb index 83fb3607a..e93161893 100644 --- a/spec/unit/shoulda/matchers/active_model/validate_numericality_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/validate_numericality_of_matcher_spec.rb @@ -1,6 +1,54 @@ require 'unit_spec_helper' describe Shoulda::Matchers::ActiveModel::ValidateNumericalityOfMatcher, type: :model do + context 'passing multiple attributes' do + it 'accepts when every attribute has the validation' do + model = define_model 'Example', attr1: :string, attr2: :string do + validates_numericality_of :attr1, :attr2 + end + + expect(model.new).to validate_numericality_of(:attr1, :attr2) + end + + it 'rejects when one attribute has the validation and one does not' do + model = define_model 'Example', attr1: :string, attr2: :string do + validates_numericality_of :attr1, allow_nil: true + end + + assertion = lambda do + expect(model.new).to validate_numericality_of(:attr1, :attr2) + end + + message = <<-MESSAGE +Expected Example to validate that :attr2 looks like a number, but this +could not be proved. + After setting :attr2 to ‹"abcd"›, the matcher expected the Example to + be invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'rejects when no attribute has the validation' do + model = define_model 'Example', attr1: :string, attr2: :string + + assertion = lambda do + expect(model.new).to validate_numericality_of(:attr1, :attr2) + end + + message = <<-MESSAGE +Expected Example to validate that :attr1 looks like a number and +validate that :attr2 looks like a number, but this could not be proved. + After setting :attr1 to ‹"abcd"›, the matcher expected the Example to + be invalid, but it was valid instead. + After setting :attr2 to ‹"abcd"›, the matcher expected the Example to + be invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + class << self def all_qualifiers # rubocop:disable Metrics/MethodLength [ diff --git a/spec/unit/shoulda/matchers/active_model/validate_presence_of_matcher_spec.rb b/spec/unit/shoulda/matchers/active_model/validate_presence_of_matcher_spec.rb index 2678efe34..98fd41cdb 100644 --- a/spec/unit/shoulda/matchers/active_model/validate_presence_of_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_model/validate_presence_of_matcher_spec.rb @@ -4,6 +4,89 @@ include UnitTests::ApplicationConfigurationHelpers context 'a model with a presence validation' do + context 'passing multiple attributes' do + it 'accepts' do + model = define_model 'Example', attr1: :string, attr2: :string do + validates_presence_of(:attr1) + validates_presence_of(:attr2) + end + + expect(model.new).to validate_presence_of(:attr1, :attr2) + end + + it 'fails when used in the negative' do + model = define_model 'Example', attr1: :string, attr2: :string do + validates_presence_of(:attr1) + end + + assertion = lambda do + expect(model.new).not_to validate_presence_of(:attr1, :attr2) + end + + message = <<-MESSAGE +Expected Example not to validate that :attr1 cannot be empty/falsy, but +this could not be proved. + After setting :attr1 to ‹nil›, the matcher expected the Example to be + valid, but it was invalid instead, producing these validation errors: + + * attr1: ["can't be blank"] + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'accepts when using qualifiers' do + model = define_model 'Example', attr1: :string, attr2: :string do + validates_presence_of(:attr1, allow_nil: true) + validates_presence_of(:attr2, allow_nil: true) + end + + expect(model.new).to validate_presence_of(:attr1, :attr2).allow_nil + end + + it 'rejects when one attribute does not match the qualifier' do + model = define_model 'Example', attr1: :string, attr2: :string do + validates_presence_of(:attr1, allow_nil: true) + validates_presence_of(:attr2) + end + + assertion = lambda do + expect(model.new).to validate_presence_of(:attr1, :attr2).allow_nil + end + + message = <<-MESSAGE +Expected Example to validate that :attr2 cannot be empty/falsy, but this +could not be proved. + After setting :attr2 to ‹nil›, the matcher expected the Example to be + valid, but it was invalid instead, producing these validation errors: + + * attr2: ["can't be blank"] + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'reports failures for every attribute that fails' do + model = define_model 'Example', attr1: :string, attr2: :string + + assertion = lambda do + expect(model.new).to validate_presence_of(:attr1, :attr2) + end + + message = <<-MESSAGE +Expected Example to validate that :attr1 cannot be empty/falsy and +validate that :attr2 cannot be empty/falsy, but this could not be +proved. + After setting :attr1 to ‹""›, the matcher expected the Example to be + invalid, but it was valid instead. + After setting :attr2 to ‹""›, the matcher expected the Example to be + invalid, but it was valid instead. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + it 'accepts' do expect(validating_presence).to matcher end diff --git a/spec/unit/shoulda/matchers/active_record/have_attached_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/have_attached_matcher_spec.rb index 92c23671f..57c1a1584 100644 --- a/spec/unit/shoulda/matchers/active_record/have_attached_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_record/have_attached_matcher_spec.rb @@ -2,6 +2,52 @@ describe Shoulda::Matchers::ActiveRecord::HaveAttachedMatcher, type: :model do describe 'have_one_attached' do + context 'passing multiple attributes' do + it 'accepts when every attached exists on the model' do + record = define_model('User') do + has_one_attached :avatar + has_one_attached :cover + end.new + + expect(record).to have_one_attached(:avatar, :cover) + end + + it 'rejects when one attached exists and one does not' do + record = define_model('User') do + has_one_attached :avatar + end.new + + assertion = lambda do + expect(record).to have_one_attached(:avatar, :cover) + end + + message = <<-MESSAGE +Expected User to have a has_one_attached called cover, but this could +not be proved. + User does not have a :cover method. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'rejects when no attached exists on the model' do + record = define_model('User').new + + assertion = lambda do + expect(record).to have_one_attached(:avatar, :cover) + end + + message = <<-MESSAGE +Expected User to have a has_one_attached called avatar and have a +has_one_attached called cover, but this could not be proved. + User does not have a :avatar method. + User does not have a :cover method. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + describe '#description' do it 'returns the message with the name of the association' do matcher = have_one_attached(:avatar) diff --git a/spec/unit/shoulda/matchers/active_record/have_readonly_attributes_matcher_spec.rb b/spec/unit/shoulda/matchers/active_record/have_readonly_attributes_matcher_spec.rb index 9a43887f4..0c2951f03 100644 --- a/spec/unit/shoulda/matchers/active_record/have_readonly_attributes_matcher_spec.rb +++ b/spec/unit/shoulda/matchers/active_record/have_readonly_attributes_matcher_spec.rb @@ -1,6 +1,50 @@ require 'unit_spec_helper' describe Shoulda::Matchers::ActiveRecord::HaveReadonlyAttributeMatcher, type: :model do + context 'passing multiple attributes' do + it 'accepts when every attribute is read-only' do + record = define_model(:example, attr1: :string, attr2: :string) do + attr_readonly :attr1, :attr2 + end.new + + expect(record).to have_readonly_attribute(:attr1, :attr2) + end + + it 'rejects when one attribute is read-only and one is not' do + record = define_model(:example, attr1: :string, attr2: :string) do + attr_readonly :attr1 + end.new + + assertion = lambda do + expect(record).to have_readonly_attribute(:attr1, :attr2) + end + + message = <<-MESSAGE +Expected Example to make attr2 read-only, but this could not be proved. + Example is making attr1 read-only, but not attr2. + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + + it 'rejects when no attribute is read-only' do + record = define_model(:example, attr1: :string, attr2: :string).new + + assertion = lambda do + expect(record).to have_readonly_attribute(:attr1, :attr2) + end + + message = <<-MESSAGE +Expected Example to make attr1 read-only and make attr2 read-only, but +this could not be proved. + Example attribute attr1 is not read-only + Example attribute attr2 is not read-only + MESSAGE + + expect(&assertion).to fail_with_message(message) + end + end + context 'a read-only attribute' do it 'accepts' do expect(with_readonly_attr).to have_readonly_attribute(:attr) diff --git a/spec/unit/shoulda/matchers/matcher_collection_spec.rb b/spec/unit/shoulda/matchers/matcher_collection_spec.rb new file mode 100644 index 000000000..5fe95c4c6 --- /dev/null +++ b/spec/unit/shoulda/matchers/matcher_collection_spec.rb @@ -0,0 +1,249 @@ +require 'unit_spec_helper' + +describe Shoulda::Matchers::MatcherCollection do + let(:fake_matcher_class) do + Class.new do + attr_reader :description, :failure_reason, :some_qualifier + + def initialize( + description:, + matches: true, + does_not_match: !matches, + failure_reason: nil + ) + @description = description + @matches = matches + @does_not_match = does_not_match + @failure_reason = failure_reason + end + + def matches?(_subject) + @matches + end + + def does_not_match?(_subject) + @does_not_match + end + + def with_some_qualifier(value) + @some_qualifier = value + self + end + end + end + + let(:fake_subject_class) do + Class.new do + def self.name + 'Example' + end + end + end + + describe '#description' do + it 'joins each wrapped matcher description with " and "' do + collection = described_class.new( + [ + fake_matcher_class.new(description: 'do A'), + fake_matcher_class.new(description: 'do B'), + ], + ) + + expect(collection.description).to eq('do A and do B') + end + end + + describe '#matches?' do + it 'returns true when every wrapped matcher matches' do + collection = described_class.new( + [ + fake_matcher_class.new(description: 'do A', matches: true), + fake_matcher_class.new(description: 'do B', matches: true), + ], + ) + + expect(collection.matches?(fake_subject_class.new)).to be(true) + end + + it 'returns false when any wrapped matcher does not match' do + collection = described_class.new( + [ + fake_matcher_class.new(description: 'do A', matches: true), + fake_matcher_class.new(description: 'do B', matches: false), + ], + ) + + expect(collection.matches?(fake_subject_class.new)).to be(false) + end + end + + describe '#does_not_match?' do + it 'returns true when no wrapped matcher matches' do + collection = described_class.new( + [ + fake_matcher_class.new(description: 'do A', does_not_match: true), + fake_matcher_class.new(description: 'do B', does_not_match: true), + ], + ) + + expect(collection.does_not_match?(fake_subject_class.new)).to be(true) + end + + it 'returns false when any wrapped matcher matches' do + collection = described_class.new( + [ + fake_matcher_class.new(description: 'do A', does_not_match: true), + fake_matcher_class.new(description: 'do B', does_not_match: false), + ], + ) + + expect(collection.does_not_match?(fake_subject_class.new)).to be(false) + end + end + + describe '#failure_message' do + it 'lists every failed matcher in the header and each reason' do + collection = described_class.new( + [ + fake_matcher_class.new( + description: 'do A', + matches: false, + failure_reason: 'A failed because of X', + ), + fake_matcher_class.new( + description: 'do B', + matches: false, + failure_reason: 'B failed because of Y', + ), + ], + ) + collection.matches?(fake_subject_class.new) + + expect(collection.failure_message).to eq(<<~MESSAGE.chomp) + Expected Example to do A and do B, but this could not be proved. + A failed because of X + B failed because of Y + MESSAGE + end + + it 'only mentions failed matchers when some wrapped matchers pass' do + collection = described_class.new( + [ + fake_matcher_class.new(description: 'do A', matches: true), + fake_matcher_class.new( + description: 'do B', + matches: false, + failure_reason: 'B failed', + ), + ], + ) + collection.matches?(fake_subject_class.new) + + expect(collection.failure_message).to eq(<<~MESSAGE.chomp) + Expected Example to do B, but this could not be proved. + B failed + MESSAGE + end + + it 'omits the reason block for matchers whose failure_reason is blank' do + collection = described_class.new( + [ + fake_matcher_class.new( + description: 'do A', + matches: false, + failure_reason: nil, + ), + fake_matcher_class.new( + description: 'do B', + matches: false, + failure_reason: nil, + ), + ], + ) + collection.matches?(fake_subject_class.new) + + expect(collection.failure_message).to eq( + 'Expected Example to do A and do B, but this could not be proved.', + ) + end + + it 'delegates to the wrapped matcher when only one matcher is present' do + single_matcher = fake_matcher_class.new( + description: 'do A', + matches: false, + failure_reason: 'A failed', + ) + def single_matcher.failure_message + 'custom failure message' + end + collection = described_class.new([single_matcher]) + collection.matches?(fake_subject_class.new) + + expect(collection.failure_message).to eq('custom failure message') + end + end + + describe '#failure_message_when_negated' do + it 'uses "not to" in the header and lists each matcher reason' do + collection = described_class.new( + [ + fake_matcher_class.new( + description: 'do A', + does_not_match: false, + failure_reason: 'A still matched', + ), + fake_matcher_class.new( + description: 'do B', + does_not_match: false, + failure_reason: 'B still matched', + ), + ], + ) + collection.does_not_match?(fake_subject_class.new) + + expect(collection.failure_message_when_negated).to eq(<<~MESSAGE.chomp) + Expected Example not to do A and do B, but this could not be proved. + A still matched + B still matched + MESSAGE + end + + it 'delegates to the wrapped matcher when only one matcher is present' do + single_matcher = fake_matcher_class.new( + description: 'do A', + does_not_match: false, + ) + def single_matcher.failure_message_when_negated + 'custom negated message' + end + collection = described_class.new([single_matcher]) + collection.does_not_match?(fake_subject_class.new) + + expect(collection.failure_message_when_negated).to eq('custom negated message') + end + end + + describe 'qualifier delegation' do + it 'forwards an unknown method to every wrapped matcher when all respond' do + matchers = [ + fake_matcher_class.new(description: 'do A'), + fake_matcher_class.new(description: 'do B'), + ] + collection = described_class.new(matchers) + + result = collection.with_some_qualifier(:foo) + + expect(matchers.map(&:some_qualifier)).to eq([:foo, :foo]) + expect(result).to be(collection) + end + + it 'raises NoMethodError when not every wrapped matcher responds' do + collection = described_class.new( + [fake_matcher_class.new(description: 'do A')], + ) + + expect { collection.totally_unknown_qualifier }. + to raise_error(NoMethodError) + end + end +end