diff --git a/CHANGELOG.md b/CHANGELOG.md index 132bdf93b..ba2293f5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Master (Unreleased) - Fix a false positive for `RSpec/LeakyLocalVariable` when variables are used only in example metadata (e.g., skip messages). ([@ydah]) +- Add autocorrect support for `RSpec/MultipleExpectations`. ([@gildesmarais]) ## 3.8.0 (2025-11-12) @@ -1006,6 +1007,7 @@ Compatibility release so users can upgrade RuboCop to 0.51.0. No new features. [@franzliedke]: https://github.com/franzliedke [@g-rath]: https://github.com/G-Rath [@geniou]: https://github.com/geniou +[@gildesmarais]: https://github.com/gildesmarais [@gsamokovarov]: https://github.com/gsamokovarov [@harry-graham]: https://github.com/harry-graham [@harrylewis]: https://github.com/harrylewis diff --git a/lib/rubocop/cop/rspec/multiple_expectations.rb b/lib/rubocop/cop/rspec/multiple_expectations.rb index bb1a32297..ebe39324c 100644 --- a/lib/rubocop/cop/rspec/multiple_expectations.rb +++ b/lib/rubocop/cop/rspec/multiple_expectations.rb @@ -66,7 +66,9 @@ module RSpec # end # end # - class MultipleExpectations < Base + class MultipleExpectations < Base # rubocop:disable Metrics/ClassLength + extend AutoCorrector + MSG = 'Example has too many expectations [%d/%d].' ANYTHING = ->(_node) { true } @@ -76,6 +78,13 @@ class MultipleExpectations < Base # @!method aggregate_failures?(node) def_node_matcher :aggregate_failures?, <<~PATTERN + (send _ _ <{ (sym :aggregate_failures) (hash <(pair (sym :aggregate_failures) _) ...>) } ...>) + PATTERN + # @!method metadata_present?(node) + def_node_matcher :metadata_present?, '(send _ _ <{sym hash} ...>)' + + # @!method aggregate_failures_definition?(node) + def_node_matcher :aggregate_failures_definition?, <<~PATTERN (block { (send _ _ <(sym :aggregate_failures) ...>) (send _ _ ... (hash <(pair (sym :aggregate_failures) %1) ...>)) @@ -110,12 +119,14 @@ def example_with_aggregate_failures?(example_node) node_with_aggregate_failures = find_aggregate_failures(example_node) return false unless node_with_aggregate_failures - aggregate_failures?(node_with_aggregate_failures, TRUE_NODE) + aggregate_failures_definition?(node_with_aggregate_failures, + TRUE_NODE) end def find_aggregate_failures(example_node) - example_node.send_node.each_ancestor(:block) - .find { |block_node| aggregate_failures?(block_node, ANYTHING) } + example_node.send_node.each_ancestor(:block).find do |block_node| + aggregate_failures_definition?(block_node, ANYTHING) + end end def find_expectation(node, &block) @@ -137,12 +148,54 @@ def flag_example(node, expectation_count:) total: expectation_count, max: max_expectations ) - ) + ) do |corrector| + autocorrect_metadata(corrector, node.send_node) + end end def max_expectations Integer(cop_config.fetch('Max', 1)) end + + def autocorrect_metadata(corrector, node) + return if aggregate_failures?(node) + + if metadata_present?(node) + add_hash_metadata(corrector, node) + else + add_symbol_metadata(corrector, node) + end + end + + def add_symbol_metadata(corrector, node) + if node.arguments.empty? + # Handle cases like `it { ... }` vs `it(...) { ... }` + loc, str = if node.loc.begin + [node.loc.begin, ':aggregate_failures'] + else + [node.loc.selector, '(:aggregate_failures)'] + end + corrector.insert_after(loc, str) + else + corrector.insert_after(node.last_argument, + ', :aggregate_failures') + end + end + + def add_hash_metadata(corrector, node) + if (hash_node = node.arguments.reverse.find(&:hash_type?)) + if hash_node.pairs.empty? + corrector.insert_before(hash_node.loc.end, + ' aggregate_failures: true ') + else + corrector.insert_after(hash_node.pairs.last, + ', aggregate_failures: true') + end + else + corrector.insert_after(node.last_argument, + ', aggregate_failures: true') + end + end end end end diff --git a/spec/rubocop/cop/rspec/multiple_expectations_spec.rb b/spec/rubocop/cop/rspec/multiple_expectations_spec.rb index 32e8a43a0..e17323e37 100644 --- a/spec/rubocop/cop/rspec/multiple_expectations_spec.rb +++ b/spec/rubocop/cop/rspec/multiple_expectations_spec.rb @@ -92,6 +92,106 @@ end RUBY end + + describe 'autocorrect' do + it 'adds :aggregate_failures when no metadata is present' do + expect_offense(<<~RUBY) + describe Foo do + it 'uses expect twice' do + ^^^^^^^^^^^^^^^^^^^^^^ Example has too many expectations [2/1]. + expect(foo).to eq(bar) + expect(baz).to eq(bar) + end + end + RUBY + + expect_correction(<<~RUBY) + describe Foo do + it 'uses expect twice', :aggregate_failures do + expect(foo).to eq(bar) + expect(baz).to eq(bar) + end + end + RUBY + end + + it 'adds parentheses when the example has no arguments' do + expect_offense(<<~RUBY) + describe Foo do + it do + ^^ Example has too many expectations [2/1]. + expect(foo).to eq(bar) + expect(baz).to eq(bar) + end + end + RUBY + + expect_correction(<<~RUBY) + describe Foo do + it(:aggregate_failures) do + expect(foo).to eq(bar) + expect(baz).to eq(bar) + end + end + RUBY + end + + it 'appends aggregate_failures to existing metadata hash arguments' do + expect_offense(<<~RUBY) + describe Foo do + it 'uses expect twice', foo: :bar do + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Example has too many expectations [2/1]. + expect(foo).to eq(bar) + expect(baz).to eq(bar) + end + end + RUBY + + expect_correction(<<~RUBY) + describe Foo do + it 'uses expect twice', foo: :bar, aggregate_failures: true do + expect(foo).to eq(bar) + expect(baz).to eq(bar) + end + end + RUBY + end + + it 'appends aggregate_failures keyword arguments when metadata exists' do + expect_offense(<<~RUBY) + describe Foo do + it 'uses expect twice', :foo do + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Example has too many expectations [2/1]. + expect(foo).to eq(bar) + expect(baz).to eq(bar) + end + end + RUBY + + expect_correction(<<~RUBY) + describe Foo do + it 'uses expect twice', :foo, aggregate_failures: true do + expect(foo).to eq(bar) + expect(baz).to eq(bar) + end + end + RUBY + end + + it 'does not autocorrect when aggregate_failures metadata exists' do + expect_offense(<<~RUBY) + describe Foo do + it 'uses expect twice', aggregate_failures: false do + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Example has too many expectations [2/1]. + expect(foo).to eq(bar) + expect(baz).to eq(bar) + end + end + RUBY + + expect_no_corrections + end + end end context 'with metadata' do