shoulda-matchers 7.0.1 → 8.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -4
  3. data/lib/shoulda/matchers/action_controller/render_with_layout_matcher.rb +1 -5
  4. data/lib/shoulda/matchers/active_model/allow_value_matcher.rb +1 -1
  5. data/lib/shoulda/matchers/active_model/helpers.rb +3 -1
  6. data/lib/shoulda/matchers/active_model/validate_absence_of_matcher.rb +25 -2
  7. data/lib/shoulda/matchers/active_model/validate_acceptance_of_matcher.rb +25 -2
  8. data/lib/shoulda/matchers/active_model/validate_comparison_of_matcher.rb +46 -11
  9. data/lib/shoulda/matchers/active_model/validate_confirmation_of_matcher.rb +25 -2
  10. data/lib/shoulda/matchers/active_model/validate_exclusion_of_matcher.rb +25 -2
  11. data/lib/shoulda/matchers/active_model/validate_inclusion_of_matcher.rb +47 -18
  12. data/lib/shoulda/matchers/active_model/validate_length_of_matcher.rb +26 -3
  13. data/lib/shoulda/matchers/active_model/validate_numericality_of_matcher.rb +46 -11
  14. data/lib/shoulda/matchers/active_model/validate_presence_of_matcher.rb +43 -17
  15. data/lib/shoulda/matchers/active_model/validation_matcher.rb +4 -8
  16. data/lib/shoulda/matchers/active_model/validator.rb +4 -0
  17. data/lib/shoulda/matchers/active_record/association_matcher.rb +119 -2
  18. data/lib/shoulda/matchers/active_record/association_matchers/optional_matcher.rb +1 -3
  19. data/lib/shoulda/matchers/active_record/association_matchers/required_matcher.rb +1 -3
  20. data/lib/shoulda/matchers/active_record/define_enum_for_matcher.rb +7 -1
  21. data/lib/shoulda/matchers/active_record/have_attached_matcher.rb +27 -2
  22. data/lib/shoulda/matchers/active_record/have_readonly_attribute_matcher.rb +26 -2
  23. data/lib/shoulda/matchers/active_record/uniqueness/namespace.rb +4 -2
  24. data/lib/shoulda/matchers/active_record/uniqueness/test_models.rb +32 -0
  25. data/lib/shoulda/matchers/active_record/validate_uniqueness_of_matcher.rb +4 -14
  26. data/lib/shoulda/matchers/matcher_collection.rb +99 -0
  27. data/lib/shoulda/matchers/rails_shim.rb +4 -4
  28. data/lib/shoulda/matchers/version.rb +1 -1
  29. data/lib/shoulda/matchers.rb +1 -0
  30. data/shoulda-matchers.gemspec +4 -3
  31. metadata +8 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63256c77182faa2d6d70c24f7f53d5cbb4c8f723ebea8ab062a912cea898d405
4
- data.tar.gz: 1bc874eac2eb72cf6e73ff4a8f642deca0f911bb1a8939b7e6d4f257fc1fb3e6
3
+ metadata.gz: 0cad2b70febc8044b3a8a61bc3ce7aa00b780708d3e0d2baccedaa3922d6f42f
4
+ data.tar.gz: 4a3fca99d9bccfac2bc74035f1e1a8608ef24775a19dd7bc30b0a979e6f87ed1
5
5
  SHA512:
6
- metadata.gz: 943c4db0909a15216ec5aa90cb01a04400cf000217f59dc70bfbfb9a4ac264466f757963ed66c79e1acc9174b4d42f445d9fac8f590d926a6699592f05108633
7
- data.tar.gz: 914455300f3ae6ceccfec6ce50cac85f201643e4b1bca4cd152a2c8a6b2c45e161d7ba8b9fb9daf41ce90c9faeaee75bef5f03b985a3d38768cba5a8a040adf9
6
+ metadata.gz: 29dcf4008451127b46e668f2399fe58d851ec54d2057595e59c7a57937b2f0e72d4a2aa4bdacd90f78d6a12a6de79f253675ab15ff4339c4222813bcaa449c04
7
+ data.tar.gz: 3f5b06b148848185c566f381f8cf3871dc56e454ea9ff59cb6106f1e969b854de53d07d84d6fa6d9554090440efcb568f30f33ac3dcfaada6fd4304fcbc284b5
data/README.md CHANGED
@@ -63,7 +63,7 @@ Start by including `shoulda-matchers` in your Gemfile:
63
63
 
64
64
  ```ruby
65
65
  group :test do
66
- gem 'shoulda-matchers', '~> 6.0'
66
+ gem 'shoulda-matchers', '~> 8.0'
67
67
  end
68
68
  ```
69
69
 
@@ -125,7 +125,7 @@ Otherwise, add `shoulda-matchers` to your Gemfile:
125
125
 
126
126
  ```ruby
127
127
  group :test do
128
- gem 'shoulda-matchers', '~> 6.0'
128
+ gem 'shoulda-matchers', '~> 8.0'
129
129
  end
130
130
  ```
131
131
 
@@ -341,6 +341,7 @@ RSpec.describe MySpecialModel, type: :model do
341
341
  end
342
342
  ```
343
343
 
344
+ <a name="should-vs-is_expectedto"></a>
344
345
  ### `should` vs `is_expected.to`
345
346
 
346
347
  In this README and throughout the documentation, you'll notice that we use the
@@ -503,14 +504,18 @@ machine, understanding the codebase, and creating a good pull request.
503
504
 
504
505
  ## Compatibility
505
506
 
506
- Shoulda Matchers is tested and supported against Ruby 3.0+, Rails
507
- 6.1+, RSpec 3.x, and Minitest 5.x.
507
+ Shoulda Matchers is tested and supported against Ruby 3.3+, Rails
508
+ 7.2+, RSpec 3.x, and Minitest 5.x.
508
509
 
509
510
  - For Ruby < 2.4 and Rails < 4.1 compatibility, please use [v3.1.3][v3.1.3].
510
511
  - For Ruby < 3.0 and Rails < 6.1 compatibility, please use [v4.5.1][v4.5.1].
512
+ - For Rails < 7.1 compatibility, please use [v6.5.0][v6.5.0].
513
+ - For Ruby < 3.3 and Rails < 7.2 compatibility, please use [v7.0.1][v7.0.1].
511
514
 
512
515
  [v3.1.3]: https://github.com/thoughtbot/shoulda-matchers/tree/v3.1.3
513
516
  [v4.5.1]: https://github.com/thoughtbot/shoulda-matchers/tree/v4.5.1
517
+ [v6.5.0]: https://github.com/thoughtbot/shoulda-matchers/tree/v6.5.0
518
+ [v7.0.1]: https://github.com/thoughtbot/shoulda-matchers/tree/v7.0.1
514
519
 
515
520
  ## Versioning
516
521
 
@@ -111,11 +111,7 @@ module Shoulda
111
111
  end
112
112
 
113
113
  def rendered_with_expected_layout?
114
- if @expected_layout.nil?
115
- true
116
- else
117
- rendered_layouts.include?(@expected_layout)
118
- end
114
+ @expected_layout.nil? || rendered_layouts.include?(@expected_layout)
119
115
  end
120
116
 
121
117
  def rendered_layouts
@@ -458,7 +458,7 @@ module Shoulda
458
458
  message << '.'
459
459
  else
460
460
  message << " producing these validation errors:\n\n"
461
- message << validator.all_formatted_validation_error_messages
461
+ message << validator.formatted_validation_error_messages
462
462
  end
463
463
  end
464
464
 
@@ -7,8 +7,10 @@ module Shoulda
7
7
  format_validation_errors(object.errors)
8
8
  end
9
9
 
10
- def format_validation_errors(errors)
10
+ def format_validation_errors(errors, attr = nil)
11
11
  list_items = errors.to_hash.keys.map do |attribute|
12
+ next if attr && attr.to_sym != attribute.to_sym
13
+
12
14
  messages = errors[attribute]
13
15
  "* #{attribute}: #{messages}"
14
16
  end
@@ -70,10 +70,33 @@ module Shoulda
70
70
  # with_message("there shall be peace on Earth")
71
71
  # end
72
72
  #
73
+ # #### Multiple attributes
74
+ #
75
+ # You can pass multiple attributes to assert that each one has the
76
+ # validation. Any qualifier chained on the matcher is applied to
77
+ # every attribute uniformly.
78
+ #
79
+ # class Robot
80
+ # include ActiveModel::Model
81
+ # attr_accessor :arms, :legs
82
+ #
83
+ # validates_absence_of :arms, :legs
84
+ # end
85
+ #
86
+ # # RSpec
87
+ # RSpec.describe Robot, type: :model do
88
+ # it { should validate_absence_of(:arms, :legs) }
89
+ # end
90
+ #
91
+ # # Minitest (Shoulda)
92
+ # class RobotTest < ActiveSupport::TestCase
93
+ # should validate_absence_of(:arms, :legs)
94
+ # end
95
+ #
73
96
  # @return [ValidateAbsenceOfMatcher}
74
97
  #
75
- def validate_absence_of(attr)
76
- ValidateAbsenceOfMatcher.new(attr)
98
+ def validate_absence_of(*attrs)
99
+ MatcherCollection.build(attrs) { |attr| ValidateAbsenceOfMatcher.new(attr) }
77
100
  end
78
101
 
79
102
  # @private
@@ -73,10 +73,33 @@ module Shoulda
73
73
  # with_message('You must accept the terms of service')
74
74
  # end
75
75
  #
76
+ # #### Multiple attributes
77
+ #
78
+ # You can pass multiple attributes to assert that each one has the
79
+ # validation. Any qualifier chained on the matcher is applied to
80
+ # every attribute uniformly.
81
+ #
82
+ # class User
83
+ # include ActiveModel::Model
84
+ # attr_accessor :terms, :privacy_policy
85
+ #
86
+ # validates_acceptance_of :terms, :privacy_policy
87
+ # end
88
+ #
89
+ # # RSpec
90
+ # RSpec.describe User, type: :model do
91
+ # it { should validate_acceptance_of(:terms, :privacy_policy) }
92
+ # end
93
+ #
94
+ # # Minitest (Shoulda)
95
+ # class UserTest < ActiveSupport::TestCase
96
+ # should validate_acceptance_of(:terms, :privacy_policy)
97
+ # end
98
+ #
76
99
  # @return [ValidateAcceptanceOfMatcher]
77
100
  #
78
- def validate_acceptance_of(attr)
79
- ValidateAcceptanceOfMatcher.new(attr)
101
+ def validate_acceptance_of(*attrs)
102
+ MatcherCollection.build(attrs) { |attr| ValidateAcceptanceOfMatcher.new(attr) }
80
103
  end
81
104
 
82
105
  # @private
@@ -272,10 +272,33 @@ module Shoulda
272
272
  # should validate_comparison_of(:age).is_greater_than(0).allow_nil
273
273
  # end
274
274
  #
275
+ # #### Multiple attributes
276
+ #
277
+ # You can pass multiple attributes to assert that each one has the
278
+ # validation. Any qualifier chained on the matcher is applied to
279
+ # every attribute uniformly.
280
+ #
281
+ # class Item
282
+ # include ActiveModel::Model
283
+ # attr_accessor :width, :height
284
+ #
285
+ # validates_comparison_of :width, :height, greater_than: 0
286
+ # end
287
+ #
288
+ # # RSpec
289
+ # RSpec.describe Item, type: :model do
290
+ # it { should validate_comparison_of(:width, :height).is_greater_than(0) }
291
+ # end
292
+ #
293
+ # # Minitest (Shoulda)
294
+ # class ItemTest < ActiveSupport::TestCase
295
+ # should validate_comparison_of(:width, :height).is_greater_than(0)
296
+ # end
297
+ #
275
298
  # @return [ValidateComparisonOfMatcher]
276
299
  #
277
- def validate_comparison_of(attr)
278
- ValidateComparisonOfMatcher.new(attr)
300
+ def validate_comparison_of(*attrs)
301
+ MatcherCollection.build(attrs) { |attr| ValidateComparisonOfMatcher.new(attr) }
279
302
  end
280
303
 
281
304
  # @private
@@ -378,6 +401,13 @@ module Shoulda
378
401
  end
379
402
  end
380
403
 
404
+ def failure_reason
405
+ raw_submatcher_failure_reason_for(
406
+ first_submatcher_that_fails_to_match,
407
+ :failure_message,
408
+ )
409
+ end
410
+
381
411
  def given_numeric_column?
382
412
  attribute_is_active_record_column? &&
383
413
  [:integer, :float, :decimal].include?(column_type)
@@ -485,21 +515,26 @@ module Shoulda
485
515
  submatcher,
486
516
  failure_message_method
487
517
  )
518
+ Shoulda::Matchers.word_wrap(
519
+ raw_submatcher_failure_reason_for(submatcher, failure_message_method),
520
+ indent: 2,
521
+ )
522
+ end
523
+
524
+ def raw_submatcher_failure_reason_for(submatcher, failure_message_method)
488
525
  failure_message = submatcher.public_send(failure_message_method)
489
526
  submatcher_description = submatcher.simple_description.
490
527
  sub(/\bvalidate that\b/, 'validates').
491
528
  sub(/\bdisallow\b/, 'disallows').
492
529
  sub(/\ballow\b/, 'allows')
493
- submatcher_message =
494
- if number_of_submatchers_for_failure_message > 1
495
- "In checking that #{model.name} #{submatcher_description}, " +
496
- failure_message[0].downcase +
497
- failure_message[1..]
498
- else
499
- failure_message
500
- end
501
530
 
502
- Shoulda::Matchers.word_wrap(submatcher_message, indent: 2)
531
+ if number_of_submatchers_for_failure_message > 1
532
+ "In checking that #{model.name} #{submatcher_description}, " +
533
+ failure_message[0].downcase +
534
+ failure_message[1..]
535
+ else
536
+ failure_message
537
+ end
503
538
  end
504
539
 
505
540
  def comparison_descriptions
@@ -70,10 +70,33 @@ module Shoulda
70
70
  # with_message('Please re-enter your password')
71
71
  # end
72
72
  #
73
+ # #### Multiple attributes
74
+ #
75
+ # You can pass multiple attributes to assert that each one has the
76
+ # validation. Any qualifier chained on the matcher is applied to
77
+ # every attribute uniformly.
78
+ #
79
+ # class User
80
+ # include ActiveModel::Model
81
+ # attr_accessor :password, :password_confirmation, :email, :email_confirmation
82
+ #
83
+ # validates_confirmation_of :password, :email
84
+ # end
85
+ #
86
+ # # RSpec
87
+ # RSpec.describe User, type: :model do
88
+ # it { should validate_confirmation_of(:password, :email) }
89
+ # end
90
+ #
91
+ # # Minitest (Shoulda)
92
+ # class UserTest < ActiveSupport::TestCase
93
+ # should validate_confirmation_of(:password, :email)
94
+ # end
95
+ #
73
96
  # @return [ValidateConfirmationOfMatcher]
74
97
  #
75
- def validate_confirmation_of(attr)
76
- ValidateConfirmationOfMatcher.new(attr)
98
+ def validate_confirmation_of(*attrs)
99
+ MatcherCollection.build(attrs) { |attr| ValidateConfirmationOfMatcher.new(attr) }
77
100
  end
78
101
 
79
102
  # @private
@@ -112,10 +112,33 @@ module Shoulda
112
112
  # with_message('You chose a puny weapon')
113
113
  # end
114
114
  #
115
+ # #### Multiple attributes
116
+ #
117
+ # You can pass multiple attributes to assert that each one has the
118
+ # validation. Any qualifier chained on the matcher is applied to
119
+ # every attribute uniformly.
120
+ #
121
+ # class Article
122
+ # include ActiveModel::Model
123
+ # attr_accessor :slug, :handle
124
+ #
125
+ # validates_exclusion_of :slug, :handle, in: %w[admin root]
126
+ # end
127
+ #
128
+ # # RSpec
129
+ # RSpec.describe Article, type: :model do
130
+ # it { should validate_exclusion_of(:slug, :handle).in_array(%w[admin root]) }
131
+ # end
132
+ #
133
+ # # Minitest (Shoulda)
134
+ # class ArticleTest < ActiveSupport::TestCase
135
+ # should validate_exclusion_of(:slug, :handle).in_array(%w[admin root])
136
+ # end
137
+ #
115
138
  # @return [ValidateExclusionOfMatcher]
116
139
  #
117
- def validate_exclusion_of(attr)
118
- ValidateExclusionOfMatcher.new(attr)
140
+ def validate_exclusion_of(*attrs)
141
+ MatcherCollection.build(attrs) { |attr| ValidateExclusionOfMatcher.new(attr) }
119
142
  end
120
143
 
121
144
  # @private
@@ -263,10 +263,33 @@ module Shoulda
263
263
  # allow_blank
264
264
  # end
265
265
  #
266
+ # #### Multiple attributes
267
+ #
268
+ # You can pass multiple attributes to assert that each one has the
269
+ # validation. Any qualifier chained on the matcher is applied to
270
+ # every attribute uniformly.
271
+ #
272
+ # class Article
273
+ # include ActiveModel::Model
274
+ # attr_accessor :status, :category
275
+ #
276
+ # validates_inclusion_of :status, :category, in: %w[draft published]
277
+ # end
278
+ #
279
+ # # RSpec
280
+ # RSpec.describe Article, type: :model do
281
+ # it { should validate_inclusion_of(:status, :category).in_array(%w[draft published]) }
282
+ # end
283
+ #
284
+ # # Minitest (Shoulda)
285
+ # class ArticleTest < ActiveSupport::TestCase
286
+ # should validate_inclusion_of(:status, :category).in_array(%w[draft published])
287
+ # end
288
+ #
266
289
  # @return [ValidateInclusionOfMatcher]
267
290
  #
268
- def validate_inclusion_of(attr)
269
- ValidateInclusionOfMatcher.new(attr)
291
+ def validate_inclusion_of(*attrs)
292
+ MatcherCollection.build(attrs) { |attr| ValidateInclusionOfMatcher.new(attr) }
270
293
  end
271
294
 
272
295
  # @private
@@ -278,18 +301,6 @@ module Shoulda
278
301
  ARBITRARY_OUTSIDE_DATE = Date.jd(9999999)
279
302
  ARBITRARY_OUTSIDE_DATETIME = DateTime.jd(9999999)
280
303
  ARBITRARY_OUTSIDE_TIME = Time.at(9999999999)
281
- BOOLEAN_ALLOWS_BOOLEAN_MESSAGE = <<EOT.freeze
282
- You are using `validate_inclusion_of` to assert that a boolean column allows
283
- boolean values and disallows non-boolean ones. Be aware that it is not possible
284
- to fully test this, as boolean columns will automatically convert non-boolean
285
- values to boolean ones. Hence, you should consider removing this test.
286
- EOT
287
- BOOLEAN_ALLOWS_NIL_MESSAGE = <<EOT.freeze
288
- You are using `validate_inclusion_of` to assert that a boolean column allows nil.
289
- Be aware that it is not possible to fully test this, as anything other than
290
- true, false or nil will be converted to false. Hence, you should consider
291
- removing this test.
292
- EOT
293
304
 
294
305
  def initialize(attribute)
295
306
  super(attribute)
@@ -400,6 +411,24 @@ EOT
400
411
 
401
412
  private
402
413
 
414
+ def boolean_allows_boolean_message
415
+ <<-EOT.strip
416
+ You are using `validate_inclusion_of` to assert that the column '#{@subject.class.name}##{attribute}'
417
+ allows boolean values and disallows non-boolean ones. Be aware that it
418
+ is not possible to fully test this, as boolean columns will automatically
419
+ convert non-boolean values to boolean ones. Hence, you should consider
420
+ removing this test.
421
+ EOT
422
+ end
423
+
424
+ def boolean_allows_nil_message
425
+ <<-EOT.strip
426
+ You are using `validate_inclusion_of` to assert that the column '#{@subject.class.name}##{attribute}'
427
+ allows nil. Be aware that it is not possible to fully test this, as anything other than
428
+ true, false or nil will be converted to false. Hence, you should consider removing this test.
429
+ EOT
430
+ end
431
+
403
432
  def minimum_range_value
404
433
  @range.begin
405
434
  end
@@ -492,11 +521,11 @@ EOT
492
521
  if attribute_type == :boolean
493
522
  case @array
494
523
  when [false, true], [true, false]
495
- Shoulda::Matchers.warn BOOLEAN_ALLOWS_BOOLEAN_MESSAGE
524
+ Shoulda::Matchers.warn boolean_allows_boolean_message
496
525
  return true
497
526
  when [nil]
498
527
  if attribute_column.null
499
- Shoulda::Matchers.warn BOOLEAN_ALLOWS_NIL_MESSAGE
528
+ Shoulda::Matchers.warn boolean_allows_nil_message
500
529
  return true
501
530
  else
502
531
  raise NonNullableBooleanError.create(@attribute)
@@ -513,11 +542,11 @@ EOT
513
542
  if attribute_type == :boolean
514
543
  case @array
515
544
  when [false, true], [true, false]
516
- Shoulda::Matchers.warn BOOLEAN_ALLOWS_BOOLEAN_MESSAGE
545
+ Shoulda::Matchers.warn boolean_allows_boolean_message
517
546
  return true
518
547
  when [nil]
519
548
  if attribute_column.null
520
- Shoulda::Matchers.warn BOOLEAN_ALLOWS_NIL_MESSAGE
549
+ Shoulda::Matchers.warn boolean_allows_nil_message
521
550
  return true
522
551
  else
523
552
  raise NonNullableBooleanError.create(@attribute)
@@ -240,7 +240,6 @@ module Shoulda
240
240
  # should validate_length_of(:bio).is_at_least(15).allow_nil
241
241
  # end
242
242
  #
243
- # @return [ValidateLengthOfMatcher]
244
243
  #
245
244
  # ##### allow_blank
246
245
  #
@@ -286,8 +285,32 @@ module Shoulda
286
285
  # should validate_length_of(:arr).as_array.is_at_least(15)
287
286
  # end
288
287
  #
289
- def validate_length_of(attr)
290
- ValidateLengthOfMatcher.new(attr)
288
+ # #### Multiple attributes
289
+ #
290
+ # You can pass multiple attributes to assert that each one has the
291
+ # validation. Any qualifier chained on the matcher is applied to
292
+ # every attribute uniformly.
293
+ #
294
+ # class User
295
+ # include ActiveModel::Model
296
+ # attr_accessor :first_name, :last_name
297
+ #
298
+ # validates_length_of :first_name, :last_name, minimum: 2
299
+ # end
300
+ #
301
+ # # RSpec
302
+ # RSpec.describe User, type: :model do
303
+ # it { should validate_length_of(:first_name, :last_name).is_at_least(2) }
304
+ # end
305
+ #
306
+ # # Minitest (Shoulda)
307
+ # class UserTest < ActiveSupport::TestCase
308
+ # should validate_length_of(:first_name, :last_name).is_at_least(2)
309
+ # end
310
+ #
311
+ # @return [ValidateLengthOfMatcher]
312
+ def validate_length_of(*attrs)
313
+ MatcherCollection.build(attrs) { |attr| ValidateLengthOfMatcher.new(attr) }
291
314
  end
292
315
 
293
316
  # @private
@@ -350,10 +350,33 @@ module Shoulda
350
350
  # should validate_numericality_of(:age).allow_nil
351
351
  # end
352
352
  #
353
+ # #### Multiple attributes
354
+ #
355
+ # You can pass multiple attributes to assert that each one has the
356
+ # validation. Any qualifier chained on the matcher is applied to
357
+ # every attribute uniformly.
358
+ #
359
+ # class Item
360
+ # include ActiveModel::Model
361
+ # attr_accessor :price, :quantity
362
+ #
363
+ # validates_numericality_of :price, :quantity
364
+ # end
365
+ #
366
+ # # RSpec
367
+ # RSpec.describe Item, type: :model do
368
+ # it { should validate_numericality_of(:price, :quantity) }
369
+ # end
370
+ #
371
+ # # Minitest (Shoulda)
372
+ # class ItemTest < ActiveSupport::TestCase
373
+ # should validate_numericality_of(:price, :quantity)
374
+ # end
375
+ #
353
376
  # @return [ValidateNumericalityOfMatcher]
354
377
  #
355
- def validate_numericality_of(attr)
356
- ValidateNumericalityOfMatcher.new(attr)
378
+ def validate_numericality_of(*attrs)
379
+ MatcherCollection.build(attrs) { |attr| ValidateNumericalityOfMatcher.new(attr) }
357
380
  end
358
381
 
359
382
  # @private
@@ -482,6 +505,13 @@ module Shoulda
482
505
  end
483
506
  end
484
507
 
508
+ def failure_reason
509
+ raw_submatcher_failure_reason_for(
510
+ first_submatcher_that_fails_to_match,
511
+ :failure_message,
512
+ )
513
+ end
514
+
485
515
  def given_numeric_column?
486
516
  attribute_is_active_record_column? &&
487
517
  [:integer, :float, :decimal].include?(column_type)
@@ -620,21 +650,26 @@ module Shoulda
620
650
  submatcher,
621
651
  failure_message_method
622
652
  )
653
+ Shoulda::Matchers.word_wrap(
654
+ raw_submatcher_failure_reason_for(submatcher, failure_message_method),
655
+ indent: 2,
656
+ )
657
+ end
658
+
659
+ def raw_submatcher_failure_reason_for(submatcher, failure_message_method)
623
660
  failure_message = submatcher.public_send(failure_message_method)
624
661
  submatcher_description = submatcher.simple_description.
625
662
  sub(/\bvalidate that\b/, 'validates').
626
663
  sub(/\bdisallow\b/, 'disallows').
627
664
  sub(/\ballow\b/, 'allows')
628
- submatcher_message =
629
- if number_of_submatchers_for_failure_message > 1
630
- "In checking that #{model.name} #{submatcher_description}, " +
631
- failure_message[0].downcase +
632
- failure_message[1..]
633
- else
634
- failure_message
635
- end
636
665
 
637
- Shoulda::Matchers.word_wrap(submatcher_message, indent: 2)
666
+ if number_of_submatchers_for_failure_message > 1
667
+ "In checking that #{model.name} #{submatcher_description}, " +
668
+ failure_message[0].downcase +
669
+ failure_message[1..]
670
+ else
671
+ failure_message
672
+ end
638
673
  end
639
674
 
640
675
  def full_allowed_type
@@ -145,10 +145,34 @@ module Shoulda
145
145
  # with_message('Robot has no legs')
146
146
  # end
147
147
  #
148
+ # #### Multiple attributes
149
+ #
150
+ # You can pass multiple attributes to assert that each one has the
151
+ # validation. Any qualifier chained on the matcher is applied to
152
+ # every attribute uniformly.
153
+ #
154
+ # class Robot
155
+ # include ActiveModel::Model
156
+ # attr_accessor :arms, :legs
157
+ #
158
+ # validates_presence_of :arms, :legs
159
+ # end
160
+ #
161
+ # # RSpec
162
+ # RSpec.describe Robot, type: :model do
163
+ # it { should validate_presence_of(:arms, :legs) }
164
+ # end
165
+ #
166
+ # # Minitest (Shoulda)
167
+ # class RobotTest < ActiveSupport::TestCase
168
+ # should validate_presence_of(:arms, :legs)
169
+ # end
170
+ #
148
171
  # @return [ValidatePresenceOfMatcher]
149
172
  #
150
- def validate_presence_of(attr)
151
- ValidatePresenceOfMatcher.new(attr)
173
+
174
+ def validate_presence_of(*attrs)
175
+ MatcherCollection.build(attrs) { |attr| ValidatePresenceOfMatcher.new(attr) }
152
176
  end
153
177
 
154
178
  # @private
@@ -206,27 +230,29 @@ module Shoulda
206
230
  "validate that :#{@attribute} cannot be empty/falsy"
207
231
  end
208
232
 
209
- def failure_message
210
- message = super
211
-
233
+ def failure_reason
234
+ reason = super
212
235
  if should_add_footnote_about_belongs_to?
213
- message << "\n\n"
214
- message << Shoulda::Matchers.word_wrap(<<-MESSAGE.strip, indent: 2)
215
- You're getting this error because #{reason_for_existing_presence_validation}.
216
- *This* presence validation doesn't use "can't be blank", the usual validation
217
- message, but "must exist" instead.
218
-
219
- With that said, did you know that the `belong_to` matcher can test this
220
- validation for you? Instead of using `validate_presence_of`, try
221
- #{suggestions_for_belongs_to}
222
- MESSAGE
236
+ "#{reason}\n\n#{belongs_to_footnote}"
237
+ else
238
+ reason
223
239
  end
224
-
225
- message
226
240
  end
227
241
 
228
242
  private
229
243
 
244
+ def belongs_to_footnote
245
+ <<~MESSAGE.strip
246
+ You're getting this error because #{reason_for_existing_presence_validation}.
247
+ *This* presence validation doesn't use "can't be blank", the usual validation
248
+ message, but "must exist" instead.
249
+
250
+ With that said, did you know that the `belong_to` matcher can test this
251
+ validation for you? Instead of using `validate_presence_of`, try
252
+ #{suggestions_for_belongs_to}
253
+ MESSAGE
254
+ end
255
+
230
256
  def secure_password_being_validated?
231
257
  Shoulda::Matchers::RailsShim.digestible_attributes_in(@subject).
232
258
  include?(@attribute)
@@ -86,6 +86,10 @@ module Shoulda
86
86
  end
87
87
  end
88
88
 
89
+ def failure_reason
90
+ last_submatcher_run.try(:failure_message)
91
+ end
92
+
89
93
  protected
90
94
 
91
95
  attr_reader :attribute, :context, :subject, :last_submatcher_run
@@ -150,14 +154,6 @@ module Shoulda
150
154
  )
151
155
  end
152
156
 
153
- def failure_reason
154
- last_submatcher_run.try(:failure_message)
155
- end
156
-
157
- def failure_reason_when_negated
158
- last_submatcher_run.try(:failure_message_when_negated)
159
- end
160
-
161
157
  def build_allow_or_disallow_value_matcher(args)
162
158
  matcher_class = args.fetch(:matcher_class)
163
159
  value = args.fetch(:value)