Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detect repeated descriptions in example groups #259

Merged
merged 2 commits into from
Dec 28, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

* Add `MessageSpies` cop for enforcing consistent style of either `expect(...).to have_received` or `expect(...).to receive`, intended as a replacement for the `MessageExpectation` cop. ([@bquorning][])
* Add `SingleArgumentMessageChain` cop for recommending use of `receive` instead of `receive_message_chain` where possible. ([@bquorning][])
* Add `RepeatedDescription` cop for detecting repeated example descriptions within example groups. ([@backus][])

## 1.8.0 (2016-10-27)

Expand Down
4 changes: 4 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,10 @@ RSpec/NotToNot:
- to_not
Enabled: true

RSpec/RepeatedDescription:
Enabled: true
Description: Check for repeated description strings in example groups.

RSpec/SingleArgumentMessageChain:
Description: Checks that chains of messages contain more than one element.
Enabled: true
Expand Down
1 change: 1 addition & 0 deletions lib/rubocop-rspec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
require 'rubocop/cop/rspec/named_subject'
require 'rubocop/cop/rspec/nested_groups'
require 'rubocop/cop/rspec/not_to_not'
require 'rubocop/cop/rspec/repeated_description'
require 'rubocop/cop/rspec/single_argument_message_chain'
require 'rubocop/cop/rspec/subject_stub'
require 'rubocop/cop/rspec/verified_doubles'
87 changes: 87 additions & 0 deletions lib/rubocop/cop/rspec/repeated_description.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
module RuboCop
module Cop
module RSpec
# Check for repeated description strings in example groups.
#
# @example
#
# # bad
# RSpec.describe User do
# it 'is valid' do
# # ...
# end
#
# it 'is valid' do
# # ...
# end
# end
#
# # good
# RSpec.describe User do
# it 'is valid when first and last name are present' do
# # ...
# end
#
# it 'is valid when last name only is present' do
# # ...
# end
# end
#
class RepeatedDescription < Cop
def_node_matcher :example, <<-PATTERN
(block $(send _ #{Examples::ALL.node_pattern_union} str ...) ...)
PATTERN

# @!method scope_change?(node)
#
# Detect if the node is an example group or shared example
#
# Selectors which indicate that we should stop searching
#
def_node_matcher :scope_change?,
(ExampleGroups::ALL + SharedGroups::ALL).block_pattern

MSG = "Don't repeat descriptions within an example group.".freeze

def on_block(node)
return unless example_group?(node)

repeated_descriptions(node).each do |repeated_hook|
add_offense(repeated_hook, :expression)
end
end

private

# Select examples in the current scope with repeated description strings
def repeated_descriptions(node)
examples_in_scope(node) # Select examples in example group
.group_by(&:method_args) # Group examples by description string
.values # Reduce to array of grouped examples
.reject(&:one?) # Reject groups with only one example
.flatten # Flatten down to array of offending nodes
end

def examples_in_scope(node, &block)
return to_enum(__method__, node) unless block_given?

node.each_child_node { |child| find_examples(child, &block) }
end

# Recursively search for examples within the current scope
#
# Searches node for examples and halts when a scope change is detected
#
# @param node [RuboCop::Node] node to recursively search for examples
#
# @yield [RuboCop::Node] discovered example nodes
def find_examples(node, &block)
return if scope_change?(node)

example(node, &block)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not sure what this method call does. It looks like it matches the node and block against a pattern, but the return value is neither stored nor returned.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry about that. I added some doc comments and I also renamed the misleading name... It was a holdover from code I borrowed from #254. Didn't realize I forgot to change the name sorry about that

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method does a recursive search until it hits a scope changing node. RuboCop's node pattern methods will yield (unless you defined a predicate method ending in ? I believe) if you give it a block and the provided node matches. By writing

def_node_pattern :desired_node, '...'
def_node_pattern :node_should_stop_recursion?, '...'

def desired_nodes(node, &block)
  node.each_child_node { |child| desired_node_search(node, &block) }
end

def desired_node_search(node, &block)
  return unless node_should_stop_recursion?(node)

  desired_node(node)
  desired_nodes(node)
end

you get a recursive node search that stops when it hits a match for node_should_stop_recursion?. The end result is that I can write

desired_nodes(node) do |match|
  add_offense(match, ...)
end

which I like. I follow this pattern in a handful of cops

examples_in_scope(node, &block)
end
end
end
end
end
4 changes: 2 additions & 2 deletions spec/rubocop/cop/rspec/described_class_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ module MyModle
RUBY
end

it 'does not flag violations within a scope change' do
it 'does not flag violations within a class scope change' do
expect_no_violations(<<-RUBY)
describe MyNamespace::MyClass do
before do
Expand All @@ -172,7 +172,7 @@ class Foo
RUBY
end

it 'does not flag violations within a scope change' do
it 'does not flag violations within a hook scope change' do
expect_no_violations(<<-RUBY)
describe do
before do
Expand Down
2 changes: 1 addition & 1 deletion spec/rubocop/cop/rspec/implicit_expect_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
expect_no_violations('it { is_expected.to_not be_truthy }')
end

it 'approves of is_expected.to_not' do
it 'approves of is_expected.not_to' do
expect_no_violations('it { is_expected.not_to be_truthy }')
end

Expand Down
76 changes: 76 additions & 0 deletions spec/rubocop/cop/rspec/repeated_description_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# frozen_string_literal: true

describe RuboCop::Cop::RSpec::RepeatedDescription do
subject(:cop) { described_class.new }

it 'registers an offense for repeated descriptions' do
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps also add the scenario where the same description is used in different context blocks.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please also test a scenario where two examples with the same description are separated by a scope change. I’m thinking e.g.

it "does x" do
end

context "something else" do
  it "works fine" do
  end
end

it "does x" do
end

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah good catch. I actually thought I had more than one test here but I guess not. I'll add a few more

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bquorning the tests should be sufficient now

expect_violation(<<-RUBY)
describe 'doing x' do
it "does x" do
^^^^^^^^^^^ Don't repeat descriptions within an example group.
end
it "does x" do
^^^^^^^^^^^ Don't repeat descriptions within an example group.
end
end
RUBY
end

it 'registers offense for repeated descriptions separated by a context' do
expect_violation(<<-RUBY)
describe 'doing x' do
it "does x" do
^^^^^^^^^^^ Don't repeat descriptions within an example group.
end
context 'during some use case' do
it "does x" do
# this should be fine
end
end
it "does x" do
^^^^^^^^^^^ Don't repeat descriptions within an example group.
end
end
RUBY
end

it 'ignores descriptions repeated in a shared context' do
expect_no_violations(<<-RUBY)
describe 'doing x' do
it "does x" do
end
shared_context 'shared behavior' do
it "does x" do
end
end
end
RUBY
end

it 'ignores repeated descriptions in a nested context' do
expect_no_violations(<<-RUBY)
describe 'doing x' do
it "does x" do
end
context 'in a certain use case' do
it "does x" do
end
end
end
RUBY
end

it 'does not flag tests which do not contain description strings' do
expect_no_violations(<<-RUBY)
describe 'doing x' do
it { foo }
it { bar }
end
RUBY
end
end