Skip to content

Commit

Permalink
Add new cops from test-prod RSpec/AggregateExamples (#72)
Browse files Browse the repository at this point in the history
  • Loading branch information
Bhacaz authored Dec 18, 2023
1 parent 1913231 commit 156db62
Show file tree
Hide file tree
Showing 11 changed files with 1,138 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
199 changes: 199 additions & 0 deletions lib/rubocop/cop/rspec/aggregate_examples.rb
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions lib/rubocop/cop/rspec/aggregate_examples/language.rb
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions lib/rubocop/cop/rspec/aggregate_examples/line_range_helpers.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 156db62

Please sign in to comment.