diff --git a/CHANGELOG.md b/CHANGELOG.md index e22d822..a380845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ # main * Enabled `RSpec/MultipleExpectations`. ([#73](https://github.com/petalmd/rubocop-petal/pull/73)) +* Added new cops from test-prod RSpec/AggregateExamples (Fix [#59](https://github.com/petalmd/rubocop-petal/issues/59)). ([#72](https://github.com/petalmd/rubocop-petal/pull/72)) # v1.2.0 (2023-09-28) diff --git a/config/default.yml b/config/default.yml index 830da66..4bfa7f7 100644 --- a/config/default.yml +++ b/config/default.yml @@ -41,6 +41,20 @@ Migration/StandaloneAddReference: Include: - db/migrate/** +RSpec/AggregateExamples: + Description: Checks if example group contains two or more aggregatable examples. + Enabled: true + StyleGuide: https://rspec.rubystyle.guide/#expectation-per-example + AddAggregateFailuresMetadata: true + MatchersWithSideEffects: + - allow_value + - allow_values + - validate_presence_of + - validate_absence_of + - validate_length_of + - validate_inclusion_of + - validates_exclusion_of + RSpec/AuthenticatedAs: Description: 'Suggest to use authenticated_as instead of legacy api_key.' Enabled: true diff --git a/lib/rubocop/cop/rspec/aggregate_examples.rb b/lib/rubocop/cop/rspec/aggregate_examples.rb new file mode 100644 index 0000000..3685354 --- /dev/null +++ b/lib/rubocop/cop/rspec/aggregate_examples.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require_relative 'aggregate_examples/matchers_with_side_effects' +require_relative 'aggregate_examples/metadata_helpers' +require_relative 'aggregate_examples/line_range_helpers' +require_relative 'aggregate_examples/node_matchers' + +# This is shamelessly borrowed from test-prof +# https://github.com/test-prof/test-prof/blob/02d8f355c158fb021e58ff1327d624a8299762b6/lib/test_prof/cops/rspec/aggregate_examples.rb + +module RuboCop + module Cop + module RSpec + # Checks if example groups contain two or more aggregatable examples. + # + # @see https://github.com/rubocop-hq/rspec-style-guide#expectation-per-example + # + # This cop is primarily for reducing the cost of repeated expensive + # context initialization. + # + # @example + # + # # bad + # describe do + # it do + # expect(number).to be_positive + # expect(number).to be_odd + # end + # + # it { is_expected.to be_prime } + # end + # + # # good + # describe do + # it do + # expect(number).to be_positive + # expect(number).to be_odd + # is_expected.to be_prime + # end + # end + # + # # fair - subject has side effects + # describe do + # it do + # expect(multiply_by(2)).to be_multiple_of(2) + # end + # + # it do + # expect(multiply_by(3)).to be_multiple_of(3) + # end + # end + # + # Block expectation syntax is deliberately not supported due to: + # + # 1. `subject { -> { ... } }` syntax being hard to detect, e.g. the + # following looks like an example with non-block syntax, but it might + # be, depending on how the subject is defined: + # + # it { is_expected.to do_something } + # + # If the subject is defined in a `shared_context`, it's impossible to + # detect that at all. + # + # 2. Aggregation should use composition with an `.and`. Also, aggregation + # of the `not_to` expectations is barely possible when a matcher + # doesn't provide a negated variant. + # + # 3. Aggregation of block syntax with non-block syntax should be in a + # specific order. + # + # RSpec [comes with an `aggregate_failures` helper](https://relishapp.com/rspec/rspec-expectations/docs/aggregating-failures) + # not to fail the example on first unmet expectation that might come + # handy with aggregated examples. + # It can be [used in metadata form](https://relishapp.com/rspec/rspec-core/docs/expectation-framework-integration/aggregating-failures#use-%60:aggregate-failures%60-metadata), + # or [enabled globally](https://relishapp.com/rspec/rspec-core/docs/expectation-framework-integration/aggregating-failures#enable-failure-aggregation-globally-using-%60define-derived-metadata%60). + # + # @example Globally enable `aggregate_failures` + # + # # spec/spec_helper.rb + # config.define_derived_metadata do |metadata| + # unless metadata.key?(:aggregate_failures) + # metadata[:aggregate_failures] = true + # end + # end + # + # To match the style being used in the spec suite, AggregateExamples + # can be configured to add `:aggregate_failures` metadata to the + # example or not. The option not to add metadata can be also used + # when it's not desired to make expectations after previously failed + # ones, commonly known as fail-fast. + # + # The terms "aggregate examples" and "aggregate failures" not to be + # confused. The former stands for putting several expectations to + # a single example. The latter means to run all the expectations in + # the example instead of aborting on the first one. + # + # @example AddAggregateFailuresMetadata: true (default) + # + # # Metadata set using a symbol + # it(:aggregate_failures) do + # expect(number).to be_positive + # expect(number).to be_odd + # end + # + # @example AddAggregateFailuresMetadata: false + # + # it do + # expect(number).to be_positive + # expect(number).to be_odd + # end + # + class AggregateExamples < ::RuboCop::Cop::Cop + include LineRangeHelpers + include MetadataHelpers + include NodeMatchers + + # Methods from the following modules override and extend methods of this + # class, extracting specific behavior. + prepend MatchersWithSideEffects + + MSG = 'Aggregate with the example at line %d.' + + def on_block(node) + example_group_with_several_examples(node) do |all_examples| + example_clusters(all_examples).each do |_, examples| + examples[1..].each do |example| + add_offense(example, + location: :expression, + message: message_for(example, examples[0])) + end + end + end + end + + def autocorrect(example_node) + clusters = example_clusters_for_autocorrect(example_node) + return if clusters.empty? + + lambda do |corrector| + clusters.each do |metadata, examples| + range = range_for_replace(examples) + replacement = aggregated_example(examples, metadata) + corrector.replace(range, replacement) + examples[1..].map { |example| drop_example(corrector, example) } + end + end + end + + private + + # Clusters of examples in the same example group, on the same nesting + # level that can be aggregated. + def example_clusters(all_examples) + all_examples + .select { |example| example_with_expectations_only?(example) } + .group_by { |example| metadata_without_aggregate_failures(example) } + .select { |_, examples| examples.count > 1 } + end + + # Clusters of examples that can be aggregated without losing any + # information (e.g. metadata or docstrings) + def example_clusters_for_autocorrect(example_node) + examples_in_group = example_node.parent.each_child_node(:block) + .select { |example| example_for_autocorrect?(example) } + example_clusters(examples_in_group) + end + + def message_for(_example, first_example) + format(MSG, first_example.loc.line) + end + + def drop_example(corrector, example) + aggregated_range = range_by_whole_lines(example.source_range, + include_final_newline: true) + corrector.remove(aggregated_range) + end + + def aggregated_example(examples, metadata) + base_indent = ' ' * examples.first.source_range.column + metadata = metadata_for_aggregated_example(metadata) + [ + "#{base_indent}it#{metadata} do", + *examples.map { |example| transform_body(example, base_indent) }, + "#{base_indent}end\n" + ].join("\n") + end + + # Extracts and transforms the body, keeping proper indentation. + def transform_body(node, base_indent) + "#{base_indent} #{new_body(node)}" + end + + def new_body(node) + node.body.source + end + end + end + end +end diff --git a/lib/rubocop/cop/rspec/aggregate_examples/language.rb b/lib/rubocop/cop/rspec/aggregate_examples/language.rb new file mode 100644 index 0000000..cf56cf7 --- /dev/null +++ b/lib/rubocop/cop/rspec/aggregate_examples/language.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# This is shamelessly borrowed from RuboCop RSpec +# https://github.com/rubocop-hq/rubocop-rspec/blob/master/lib/rubocop/rspec/language.rb +module RuboCop + module Cop + module RSpec + # RSpec public API methods that are commonly used in cops + class AggregateExamples < ::RuboCop::Cop::Cop + module Language + RSPEC = '{(const {nil? cbase} :RSpec) nil?}' + + # Set of method selectors + class SelectorSet + def initialize(selectors) + @selectors = selectors + end + + def ==(other) + selectors.eql?(other.selectors) + end + + def +(other) + self.class.new(selectors + other.selectors) + end + + delegate :include?, to: :selectors + + def block_pattern + "(block #{send_pattern} ...)" + end + + def send_pattern + "(send #{RSPEC} #{node_pattern_union} ...)" + end + + def node_pattern_union + "{#{node_pattern}}" + end + + def node_pattern + selectors.map(&:inspect).join(' ') + end + + protected + + attr_reader :selectors + end + + module ExampleGroups + GROUPS = SelectorSet.new(%i[describe context feature example_group]) + SKIPPED = SelectorSet.new(%i[xdescribe xcontext xfeature]) + FOCUSED = SelectorSet.new(%i[fdescribe fcontext ffeature]) + + ALL = GROUPS + SKIPPED + FOCUSED + end + + module Examples + EXAMPLES = SelectorSet.new(%i[it specify example scenario its]) + FOCUSED = SelectorSet.new(%i[fit fspecify fexample fscenario focus]) + SKIPPED = SelectorSet.new(%i[xit xspecify xexample xscenario skip]) + PENDING = SelectorSet.new(%i[pending]) + + ALL = EXAMPLES + FOCUSED + SKIPPED + PENDING + end + + module Runners + ALL = SelectorSet.new(%i[to to_not not_to]) + end + end + end + end + end +end diff --git a/lib/rubocop/cop/rspec/aggregate_examples/line_range_helpers.rb b/lib/rubocop/cop/rspec/aggregate_examples/line_range_helpers.rb new file mode 100644 index 0000000..d50a873 --- /dev/null +++ b/lib/rubocop/cop/rspec/aggregate_examples/line_range_helpers.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module RSpec + class AggregateExamples < ::RuboCop::Cop::Cop + # @internal Support methods for keeping newlines around examples. + module LineRangeHelpers + include RangeHelp + + private + + def range_for_replace(examples) + range = range_by_whole_lines(examples.first.source_range, + include_final_newline: true) + next_range = range_by_whole_lines(examples[1].source_range) + if adjacent?(range, next_range) + range.resize(range.length + 1) + else + range + end + end + + def adjacent?(range, another_range) + range.end_pos + 1 == another_range.begin_pos + end + end + end + end + end +end diff --git a/lib/rubocop/cop/rspec/aggregate_examples/matchers_with_side_effects.rb b/lib/rubocop/cop/rspec/aggregate_examples/matchers_with_side_effects.rb new file mode 100644 index 0000000..7485788 --- /dev/null +++ b/lib/rubocop/cop/rspec/aggregate_examples/matchers_with_side_effects.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require_relative 'language' + +module RuboCop + module Cop + module RSpec + class AggregateExamples < ::RuboCop::Cop::Cop + # When aggregated, the expectations will fail when not supposed to or + # have a risk of not failing when expected to. One example is + # `validate_presence_of :comment` as it leaves an empty comment after + # itself on the subject making it invalid and the subsequent expectation + # to fail. + # Examples with those matchers are not supposed to be aggregated. + # + # @example MatchersWithSideEffects + # + # # .rubocop.yml + # # RSpec/AggregateExamples: + # # MatchersWithSideEffects: + # # - allow_value + # # - allow_values + # # - validate_presence_of + # + # # bad, but isn't automatically correctable + # describe do + # it { is_expected.to validate_presence_of(:comment) } + # it { is_expected.to be_valid } + # end + # + # @internal + # Support for taking special care of the matchers that have side + # effects, i.e. leave the subject in a modified state. + module MatchersWithSideEffects + extend RuboCop::NodePattern::Macros + include Language + + MSG_FOR_EXPECTATIONS_WITH_SIDE_EFFECTS = + 'Aggregate with the example at line %d. IMPORTANT! Pay attention ' \ + 'to the expectation order, some of the matchers have side effects.' + + private + + def message_for(example, first_example) + return super unless example_with_side_effects?(example) + + format(MSG_FOR_EXPECTATIONS_WITH_SIDE_EFFECTS, first_example.loc.line) + end + + def matcher_with_side_effects_names + cop_config.fetch('MatchersWithSideEffects', []) + .map(&:to_sym) + end + + def matcher_with_side_effects_name?(matcher_name) + matcher_with_side_effects_names.include?(matcher_name) + end + + # In addition to base definition, matches examples with: + # - no matchers known to have side-effects + def_node_matcher :example_for_autocorrect?, <<-PATTERN + [ #super !#example_with_side_effects? ] + PATTERN + + # Matches the example with matcher with side effects + def_node_matcher :example_with_side_effects?, <<-PATTERN + (block #{Examples::EXAMPLES.send_pattern} _ #expectation_with_side_effects?) + PATTERN + + # Matches the expectation with matcher with side effects + def_node_matcher :expectation_with_side_effects?, <<-PATTERN + (send #expectation? #{Runners::ALL.node_pattern_union} #matcher_with_side_effects?) + PATTERN + + # Matches the matcher with side effects + def_node_search :matcher_with_side_effects?, <<-PATTERN + (send nil? #matcher_with_side_effects_name? ...) + PATTERN + end + end + end + end +end diff --git a/lib/rubocop/cop/rspec/aggregate_examples/metadata_helpers.rb b/lib/rubocop/cop/rspec/aggregate_examples/metadata_helpers.rb new file mode 100644 index 0000000..4723e05 --- /dev/null +++ b/lib/rubocop/cop/rspec/aggregate_examples/metadata_helpers.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module RSpec + class AggregateExamples < ::RuboCop::Cop::Cop + # @internal + # Support methods for example metadata. + # Examples with similar metadata are grouped. + # + # Depending on the configuration, `aggregate_failures` metadata + # is added to aggregated examples. + module MetadataHelpers + private + + def metadata_for_aggregated_example(metadata) + metadata_to_add = metadata.compact.map(&:source) + metadata_to_add.unshift(':aggregate_failures') if add_aggregate_failures_metadata? + if metadata_to_add.any? + "(#{metadata_to_add.join(', ')})" + else + '' + end + end + + # Used to group examples for aggregation. `aggregate_failures` + # and `aggregate_failures: true` metadata are not taken in + # consideration, as it is dynamically set basing on cofiguration. + # If `aggregate_failures: false` is set on the example, it's + # preserved and is treated as regular metadata. + def metadata_without_aggregate_failures(example) + metadata = example_metadata(example) || [] + + symbols = metadata_symbols_without_aggregate_failures(metadata) + pairs = metadata_pairs_without_aggegate_failures(metadata) + + [*symbols, pairs].flatten.compact + end + + def example_metadata(example) + example.send_node.arguments + end + + def metadata_symbols_without_aggregate_failures(metadata) + metadata + .select(&:sym_type?) + .reject { |item| item.value == :aggregate_failures } + end + + def metadata_pairs_without_aggegate_failures(metadata) + map = metadata.find(&:hash_type?) + pairs = map&.pairs || [] + pairs.reject do |pair| + pair.key.value == :aggregate_failures && pair.value.true_type? + end + end + + def add_aggregate_failures_metadata? + cop_config.fetch('AddAggregateFailuresMetadata', false) + end + end + end + end + end +end diff --git a/lib/rubocop/cop/rspec/aggregate_examples/node_matchers.rb b/lib/rubocop/cop/rspec/aggregate_examples/node_matchers.rb new file mode 100644 index 0000000..217f2a8 --- /dev/null +++ b/lib/rubocop/cop/rspec/aggregate_examples/node_matchers.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require_relative 'language' + +module RuboCop + module Cop + module RSpec + class AggregateExamples < ::RuboCop::Cop::Cop + # @internal + # Node matchers and searchers. + module NodeMatchers + extend RuboCop::NodePattern::Macros + include Language + + private + + def_node_matcher :example_group_with_several_examples, <<-PATTERN + (block + #{ExampleGroups::ALL.send_pattern} + _ + (begin $...) + ) + PATTERN + + def example_method?(method_name) + %i[it specify example scenario].include?(method_name) + end + + # Matches examples with: + # - expectation statements exclusively + # - no title (e.g. `it('jumps over the lazy dog')`) + # - no HEREDOC + def_node_matcher :example_for_autocorrect?, <<-PATTERN + [ + #example_with_expectations_only? + !#example_has_title? + !#contains_heredoc? + ] + PATTERN + + def_node_matcher :example_with_expectations_only?, <<-PATTERN + (block #{Examples::EXAMPLES.send_pattern} _ + { #single_expectation? (begin #single_expectation?+) } + ) + PATTERN + + # Matches the example with a title (e.g. `it('is valid')`) + def_node_matcher :example_has_title?, <<-PATTERN + (block + (send nil? #example_method? str ...) + ... + ) + PATTERN + + # Searches for HEREDOC in examples. It can be tricky to aggregate, + # especially when interleaved with parenthesis or curly braces. + def contains_heredoc?(node) + node.each_descendant(:str, :xstr, :dstr).any?(&:heredoc?) + end + + def_node_matcher :subject_with_no_args?, <<-PATTERN + (send _ _) + PATTERN + + def_node_matcher :expectation?, <<-PATTERN + { + (send nil? {:is_expected :are_expected}) + (send nil? :expect #subject_with_no_args?) + } + PATTERN + + def_node_matcher :single_expectation?, <<-PATTERN + (send #expectation? #{Runners::ALL.node_pattern_union} _) + PATTERN + end + end + end + end +end diff --git a/spec/rubocop/cop/migration/foreign_key_option_spec.rb b/spec/rubocop/cop/migration/foreign_key_option_spec.rb index 0a07e99..0aac6a7 100644 --- a/spec/rubocop/cop/migration/foreign_key_option_spec.rb +++ b/spec/rubocop/cop/migration/foreign_key_option_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe RuboCop::Cop::Migration::ForeignKeyOption, :config do - it 'registers an offense when not specifying the foreign key option' do + it 'registers an offense when not iting the foreign key option' do expect_offense(<<~RUBY) add_reference :products, :user ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Add `foreign_key: true` or `foreign_key: { to_table: :some_table }` diff --git a/spec/rubocop/cop/rspec/aggregate_examples/matchers_with_side_effects_spec.rb b/spec/rubocop/cop/rspec/aggregate_examples/matchers_with_side_effects_spec.rb new file mode 100644 index 0000000..80b3f9f --- /dev/null +++ b/spec/rubocop/cop/rspec/aggregate_examples/matchers_with_side_effects_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.describe RuboCop::Cop::RSpec::AggregateExamples, + '.matchers_with_side_effects', + :config do + subject(:cop) { described_class.new(config) } + + let(:all_cops_config) do + { 'DisplayCopNames' => false } + end + + context 'without side effect matchers defined in configuration' do + let(:cop_config) do + { 'MatchersWithSideEffects' => [] } + end + + it 'flags all examples' do + expect_offense(<<~RUBY) + describe do + it { expect(entry).to validate_absence_of(:comment) } + it { expect(entry).to validate_presence_of(:description) } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + end + RUBY + + expect_correction(<<~RUBY) + describe do + it(:aggregate_failures) do + expect(entry).to validate_absence_of(:comment) + expect(entry).to validate_presence_of(:description) + end + end + RUBY + end + end + + context 'with default configuration' do + let(:cop_config) { {} } + + it 'flags without qualifiers, but does not autocorrect' do + expect_offense(<<~RUBY) + describe 'with and without side effects' do + it { expect(fruit).to be_good } + it { expect(fruit).to validate_presence_of(:color) } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. IMPORTANT! Pay attention to the expectation order, some of the matchers have side effects. + end + RUBY + + expect_no_corrections + end + + it 'flags with qualifiers, but does not autocorrect' do + expect_offense(<<~RUBY) + describe 'with and without side effects' do + it { expect(fruit).to be_good } + it { expect(fruit).to allow_value('green').for(:color) } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. IMPORTANT! Pay attention to the expectation order, some of the matchers have side effects. + it { expect(fruit).to allow_value('green').for(:color).for(:type => :apple) } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. IMPORTANT! Pay attention to the expectation order, some of the matchers have side effects. + it { expect(fruit).to allow_value('green').for(:color).for(:type => :apple).during(:summer) } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. IMPORTANT! Pay attention to the expectation order, some of the matchers have side effects. + end + RUBY + + expect_no_corrections + end + end +end diff --git a/spec/rubocop/cop/rspec/aggregate_examples_spec.rb b/spec/rubocop/cop/rspec/aggregate_examples_spec.rb new file mode 100644 index 0000000..2a65a64 --- /dev/null +++ b/spec/rubocop/cop/rspec/aggregate_examples_spec.rb @@ -0,0 +1,523 @@ +# frozen_string_literal: true + +RSpec.describe RuboCop::Cop::RSpec::AggregateExamples, :config do + subject(:cop) { described_class.new(config) } + + let(:all_cops_config) do + { 'DisplayCopNames' => false } + end + + let(:cop_config) do + { 'AddAggregateFailuresMetadata' => false } + end + + shared_examples 'flags in example group' do |group| + it "flags examples in '#{group}'" do + expect_offense(<<~RUBY) + #{group} 'some docstring' do + it { is_expected.to be_awesome } + it { expect(subject).to be_amazing } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + it { expect(article).to be_brilliant } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + end + RUBY + + expect_correction(<<~RUBY) + #{group} 'some docstring' do + it do + is_expected.to be_awesome + expect(subject).to be_amazing + expect(article).to be_brilliant + end + end + RUBY + end + end + + # Detects aggregatable examples inside all aliases of example groups. + it_behaves_like 'flags in example group', :context + it_behaves_like 'flags in example group', :describe + it_behaves_like 'flags in example group', :feature + it_behaves_like 'flags in example group', :example_group + + # Non-expectation statements can have side effects, when e.g. being + # part of the setup of the example. + # Examples containing expectations wrapped in a method call, e.g. + # `expect_no_corrections` are not considered aggregatable. + it 'ignores examples with non-expectation statements' do + expect_no_offenses(<<~RUBY) + describe do + it do + something + expect(book).to be_cool + end + it { expect(book).to be_awesome } + end + RUBY + end + + # Both one-line examples and examples spanning multiple lines can be + # aggregated, in case they consist only of expectation statements. + it 'flags a leading single expectation example' do + expect_offense(<<~RUBY) + describe do + it { expect(candidate).to be_positive } + it do + ^^^^^ Aggregate with the example at line 2. + expect(subject).to be_enthusiastic + is_expected.to be_skilled + end + end + RUBY + + expect_correction(<<~RUBY) + describe do + it do + expect(candidate).to be_positive + expect(subject).to be_enthusiastic + is_expected.to be_skilled + end + end + RUBY + end + + it 'flags a following single expectation example' do + expect_offense(<<~RUBY) + describe do + it do + expect(subject).to be_enthusiastic + is_expected.to be_skilled + end + it { expect(candidate).to be_positive } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + end + RUBY + + expect_correction(<<~RUBY) + describe do + it do + expect(subject).to be_enthusiastic + is_expected.to be_skilled + expect(candidate).to be_positive + end + end + RUBY + end + + it 'flags an expectation with compound matchers' do + expect_offense(<<~RUBY) + describe do + it do + expect(candidate) + .to be_enthusiastic + .and be_hard_working + end + it { is_expected.to be_positive } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + end + RUBY + + expect_correction(<<~RUBY) + describe do + it do + expect(candidate) + .to be_enthusiastic + .and be_hard_working + is_expected.to be_positive + end + end + RUBY + end + + # Not just consecutive examples can be aggregated. + it 'flags scattered aggregatable examples' do + expect_offense(<<~RUBY) + describe do + it { expect(life).to be_first } + it do + foo + expect(bar).to be_foo + end + it { expect(work).to be_second } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + it do + bar + expect(foo).to be_bar + end + it { expect(other).to be_third } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + end + RUBY + + expect_correction(<<~RUBY) + describe do + it do + expect(life).to be_first + expect(work).to be_second + expect(other).to be_third + end + it do + foo + expect(bar).to be_foo + end + it do + bar + expect(foo).to be_bar + end + end + RUBY + end + + # When examples have docstrings, it is incorrect to aggregate them, since + # either docstring is lost, either it needs to be joined with the others, + # which is an error-prone transformation. + it 'flags example with docstring, but does not autocorrect' do + expect_offense(<<~RUBY) + describe do + it('is awesome') { expect(drink).to be_awesome } + it { expect(drink).to be_cool } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + end + RUBY + + expect_no_corrections + end + + it 'flags several examples with docstrings, but does not autocorrect' do + expect_offense(<<~RUBY) + describe do + it('is awesome') { expect(drink).to be_awesome } + it('is cool') { expect(drink).to be_cool } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + end + RUBY + + expect_no_corrections + end + + # Breaks examples into groups with similar metadata. + # `aggregate_failures: true` is considered a helper metadata, and is + # removed during aggregation. + it 'flags examples with hash metadata' do + expect_offense(<<~RUBY) + describe do + it { expect(ambient_temperature).to be_mild } + it(freeze: -30) { expect(ambient_temperature).to be_cold } + it(aggregate_failures: true) { expect(ambient_temperature).to be_warm } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + it(freeze: -30, aggregate_failures: true) { expect(ambient_temperature).to be_chilly } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 3. + it(aggregate_failures: true, freeze: -30) { expect(ambient_temperature).to be_cool } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 3. + it(aggregate_failures: false) { expect(ambient_temperature).to be_tolerable } + end + RUBY + + expect_correction(<<~RUBY) + describe do + it do + expect(ambient_temperature).to be_mild + expect(ambient_temperature).to be_warm + end + it(freeze: -30) do + expect(ambient_temperature).to be_cold + expect(ambient_temperature).to be_chilly + expect(ambient_temperature).to be_cool + end + it(aggregate_failures: false) { expect(ambient_temperature).to be_tolerable } + end + RUBY + end + + # Same as above + it 'flags examples with symbol metadata' do + expect_offense(<<~RUBY) + describe do + it { expect(fruit).to be_so_so } + it(:peach) { expect(fruit).to be_awesome } + it(:peach, aggregate_failures: true) { expect(fruit).to be_cool } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 3. + it(:peach, :aggregate_failures) { expect(fruit).to be_amazing } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 3. + it(aggregate_failures: false) { expect(ambient_temperature).to be_tolerable } + end + RUBY + + expect_correction(<<~RUBY) + describe do + it { expect(fruit).to be_so_so } + it(:peach) do + expect(fruit).to be_awesome + expect(fruit).to be_cool + expect(fruit).to be_amazing + end + it(aggregate_failures: false) { expect(ambient_temperature).to be_tolerable } + end + RUBY + end + + it 'flags examples with both metadata and docstrings, but does not autocorrect' do + expect_offense(<<~RUBY) + describe do + it { expect(dragonfruit).to be_so_so } + it(:awesome) { expect(dragonfruit).to be_awesome } + it('is ok', :awesome) { expect(dragonfruit).to be_ok } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 3. + end + RUBY + + expect_no_corrections + end + + # Examples with similar metadata of mixed types are aggregated. + it 'flags examples with mixed types of metadata' do + expect_offense(<<~RUBY) + describe do + it { expect(data).to be_ok } + it(:model, isolation: :full) { expect(data).to be_isolated } + it(:model, isolation: :full) { expect(data).to be_saved } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 3. + end + RUBY + + expect_correction(<<~RUBY) + describe do + it { expect(data).to be_ok } + it(:model, isolation: :full) do + expect(data).to be_isolated + expect(data).to be_saved + end + end + RUBY + end + + it 'ignores examples defined in the loop' do + expect_no_offenses(<<~RUBY) + describe do + [1, 2, 3].each do + it { expect(weather).to be_mild } + end + end + RUBY + end + + it 'flags examples with HEREDOC, but does not autocorrect' do + expect_offense(<<~RUBY) + describe do + it do + expect(text).to span_couple_lines <<~TEXT + Multiline text. + Second line. + TEXT + end + it { expect(text).to be_ok } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + end + RUBY + + expect_no_corrections + end + + it 'flags examples with HEREDOC interleaved with parenthesis and curly brace, but does not autocorrect' do + expect_offense(<<~RUBY) + describe do + it { expect(text).to span_couple_lines(<<~TEXT) } + I would be quite surprised to see this in the code. + But it's real! + TEXT + it { expect(text).to be_ok } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + end + RUBY + + expect_no_corrections + end + + it 'ignores block expectation syntax' do + expect_no_offenses(<<~RUBY) + describe do + it do + expect { something }.to do_something + end + + it do + expect { something }.to do_something_else + end + end + RUBY + end + + it 'flags examples with expectations with a property of something as subject' do + expect_offense(<<~RUBY) + describe do + it { expect(division.result).to eq(5) } + it { expect(division.modulo).to eq(3) } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + end + RUBY + + expect_correction(<<~RUBY) + describe do + it do + expect(division.result).to eq(5) + expect(division.modulo).to eq(3) + end + end + RUBY + end + + # Helper methods have a good chance of having side effects, and are + # not aggregated. + it 'ignores helper method as subject' do + expect_no_offenses(<<~RUBY) + describe do + it do + expect(multiply_by(2)).to be_multiple_of(2) + end + + it do + expect(multiply_by(3)).to be_multiple_of(3) + end + end + RUBY + end + + # Examples from different contexts (examples groups) are not aggregated. + it 'ignores nested example groups' do + expect_no_offenses(<<~RUBY) + describe do + it { expect(syntax_check).to be_ok } + + context do + it { expect(syntax_check).to be_ok } + end + + context do + it { expect(syntax_check).to be_ok } + end + end + RUBY + end + + it 'flags aggregatable examples and nested example groups' do + expect_offense(<<~RUBY) + describe do + it { expect(pressure).to be_ok } + it { expect(pressure).to be_alright } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + + context do + it { expect(pressure).to be_awful } + end + end + RUBY + + expect_correction(<<~RUBY) + describe do + it do + expect(pressure).to be_ok + expect(pressure).to be_alright + end + + context do + it { expect(pressure).to be_awful } + end + end + RUBY + end + + it 'flags in the root context' do + expect_offense(<<~RUBY) + RSpec.describe do + it { expect(person).to be_positive } + it { expect(person).to be_enthusiastic } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + end + RUBY + + expect_correction(<<~RUBY) + RSpec.describe do + it do + expect(person).to be_positive + expect(person).to be_enthusiastic + end + end + RUBY + end + + it 'flags several examples separated by newlines' do + expect_offense(<<~RUBY) + describe do + it { expect(person).to be_positive } + + it { expect(person).to be_enthusiastic } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + end + RUBY + + expect_correction(<<~RUBY) + describe do + it do + expect(person).to be_positive + expect(person).to be_enthusiastic + end + end + RUBY + end + + it 'flags scattered examples separated by newlines' do + expect_offense(<<~RUBY) + describe do + it { expect(person).to be_positive } + + it { expect { something }.to do_something } + it { expect(person).to be_enthusiastic } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + end + RUBY + + expect_correction(<<~RUBY) + describe do + it do + expect(person).to be_positive + expect(person).to be_enthusiastic + end + + it { expect { something }.to do_something } + end + RUBY + end + + context 'when AddAggregateFailuresMetadata is true' do + let(:cop_config) do + { 'AddAggregateFailuresMetadata' => true } + end + + it 'flags examples' do + expect_offense(<<~RUBY) + describe do + it { expect(life).to be_first } + it { expect(work).to be_second } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 2. + it(:follow, allow: true) { expect(life).to be_first } + it(:follow, allow: true) { expect(work).to be_second } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Aggregate with the example at line 4. + end + RUBY + + expect_correction(<<~RUBY) + describe do + it(:aggregate_failures) do + expect(life).to be_first + expect(work).to be_second + end + it(:aggregate_failures, :follow, allow: true) do + expect(life).to be_first + expect(work).to be_second + end + end + RUBY + end + end +end