Skip to content

Commit

Permalink
Spec pass
Browse files Browse the repository at this point in the history
  • Loading branch information
Bhacaz committed Dec 15, 2023
1 parent 7482dca commit 5af29dc
Show file tree
Hide file tree
Showing 11 changed files with 1,372 additions and 0 deletions.
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
201 changes: 201 additions & 0 deletions lib/rubocop/cop/rspec/aggregate_examples.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
# frozen_string_literal: true

require_relative 'aggregate_examples/its'
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
# specify do
# expect(number).to be_positive
# expect(number).to be_odd
# end
#
# it { is_expected.to be_prime }
# end
#
# # good
# describe do
# specify 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
# specify do
# expect(multiply_by(2)).to be_multiple_of(2)
# end
#
# specify 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
# specify(:aggregate_failures) do
# expect(number).to be_positive
# expect(number).to be_odd
# end
#
# @example AddAggregateFailuresMetadata: false
#
# specify 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 Its
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..-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..-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}specify#{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
99 changes: 99 additions & 0 deletions lib/rubocop/cop/rspec/aggregate_examples/its.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# frozen_string_literal: true

module RuboCop
module Cop
module RSpec
class AggregateExamples < ::RuboCop::Cop::Cop
# @example `its`
#
# # Supports regular `its` call with an attribute/method name,
# # or a chain of methods expressed as a string with dots.
#
# its(:one) { is_expected.to be(true) }
# its('two') { is_expected.to be(false) }
# its('phone_numbers.size') { is_expected.to be 2 }
#
# @example `its` with single-element array argument
#
# # Also supports single-element array argument.
#
# its(['headers']) { is_expected.to include(encoding: 'text') }
#
# @example `its` with multi-element array argument is ambiguous
#
# # Does not support `its` with multi-element array argument due to
# # an ambiguity. Transformation depends on the type of the subject:
# # - a Hash: `hash[element1][element2]...`
# # - and arbitrary type: `hash[element1, element2, ...]`
# # It is impossible to infer the type to propose a proper correction.
#
# its(['ambiguous', 'elements']) { ... }
#
# @example `its` with metadata
#
# its('header', html: true) { is_expected.to include(text: 'hello') }
#
module Its
extend RuboCop::NodePattern::Macros

private

# It's impossible to aggregate `its` body as is, it needs to be
# converted to `expect(subject.something).to ...`
def new_body(node)
return super unless its?(node)

transform_its(node.body, node.send_node.arguments)
end

def transform_its(body, arguments)
argument = arguments.first
replacement =
case argument.type
when :array
key = argument.values.first
"expect(subject[#{key.source}])"
else
property = argument.value
"expect(subject.#{property})"
end
body.source.gsub(/is_expected|are_expected/, replacement)
end

def example_metadata(example)
return super unless its?(example.send_node)

# First parameter to `its` is not metadata.
example.send_node.arguments[1..-1]
end

def its?(node)
node.method_name == :its
end

# In addition to base definition, matches examples with:
# - no `its` with an multiple-element array argument due to
# an ambiguity, when SUT can be a hash, and result will be defined
# by calling `[]` on SUT subsequently, e.g. `subject[one][two]`,
# or any other type of object implementing `[]`, and then all the
# array arguments are passed to `[]`, e.g. `subject[one, two]`.
def_node_matcher :example_for_autocorrect?, <<-PATTERN
[
#super
!#its_with_multi_element_array_argument?
!#its_with_send_or_var_argument?
]
PATTERN

def_node_matcher :its_with_multi_element_array_argument?, <<-PATTERN
(block (send nil? :its (array _ _ ...)) ...)
PATTERN

def_node_matcher :its_with_send_or_var_argument?, <<-PATTERN
(block (send nil? :its { send lvar ivar cvar gvar const }) ...)
PATTERN
end
end
end
end
end
Loading

0 comments on commit 5af29dc

Please sign in to comment.