Skip to content

Add new RSpec/DiscardedMatcher cop#2163

Open
ydakuka wants to merge 11 commits intorubocop:masterfrom
ydakuka:add-new-rspec-discarded-matcher
Open

Add new RSpec/DiscardedMatcher cop#2163
ydakuka wants to merge 11 commits intorubocop:masterfrom
ydakuka:add-new-rspec-discarded-matcher

Conversation

@ydakuka
Copy link

@ydakuka ydakuka commented Feb 21, 2026

Fixes rubocop/rubocop#14772


Before submitting the PR make sure the following are checked:

  • Feature branch is up-to-date with master (if not - rebase it).
  • Squashed related commits together.
  • Added tests.
  • Updated documentation.
  • Added an entry to the CHANGELOG.md if the new code introduces user-observable changes.
  • The build (bundle exec rake) passes (be sure to run this locally, since it may produce updated documentation that you will need to commit).

If you have created a new cop:

  • Added the new cop to config/default.yml.
  • The cop is configured as Enabled: pending in config/default.yml.
  • The cop is configured as Enabled: true in .rubocop.yml.
  • The cop documents examples of good and bad code.
  • The tests assert both that bad code is reported and that good code is not reported.
  • Set VersionAdded: "<<next>>" in default/config.yml.

If you have modified an existing cop's configuration options:

  • Set VersionChanged: "<<next>>" in config/default.yml.

@ydakuka ydakuka requested a review from a team as a code owner February 21, 2026 15:33
@ydakuka ydakuka force-pushed the add-new-rspec-discarded-matcher branch from 46f7545 to 680ff12 Compare February 21, 2026 15:41
Comment on lines +15 to +16
# expect { result }.to \
# change { obj.foo }.from(1).to(2)
Copy link
Member

Choose a reason for hiding this comment

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

expect { ... }
  .to ...

is more common?
But it does make the discarded matcher problem more obvious.

expect { result }
  .to change { obj.foo }.from(1).to(2)
  change { obj.bar }.from(3).to(4)

quite easy to spot.

Comment on lines +37 to +40
change
receive
receive_messages
receive_message_chain
Copy link
Member

Choose a reason for hiding this comment

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

Is it correct to say that any block matcher is prone to this?
Can we externalize to our language conf this and make it user-configurable, too to allow adding custom matchers?

Comment on lines +110 to +112
def inside_example?(node)
node.each_ancestor(:block).any? { |ancestor| example?(ancestor) }
end
Copy link
Member

Choose a reason for hiding this comment

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

Now when it's used in three cops, does it make sense to extract to a module?


it 'registers an offense for standalone `change` in a one-liner example' do
expect_offense(<<~RUBY)
specify { change { obj.bar }.from(1).to(2) }
Copy link
Member

Choose a reason for hiding this comment

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

I've seen this a couple of times with value matchers, too:

it { validate_precense_of(:email) }

But it should be detected already by https://docs.rubocop.org/rubocop-rspec/cops_rspec.html#rspecnoexpectationexample

Comment on lines +104 to +112
it 'does not register an offense for `change` at end of non-example block' do
expect_no_offenses(<<~RUBY)
specify do
[1, 2, 3].each do |n|
change { obj.foo }.from(n).to(n + 1)
end
end
RUBY
end
Copy link
Member

Choose a reason for hiding this comment

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

Are there legitimate cases for which we want to ignore this?

specify do
if change { obj.foo }.from(1).to(2)
something
end
Copy link
Member

Choose a reason for hiding this comment

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

Why would anyone do this?

Comment on lines +265 to +269
specify do
case change { obj.foo }.from(1).to(2)
when 1 then something
end
end
Copy link
Member

Choose a reason for hiding this comment

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

I can't imagine a real spec using this

Comment on lines +255 to +258
result = case condition
when :update then something
else change { obj.baz }.from(5).to(6)
end
Copy link
Member

Choose a reason for hiding this comment

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

This seems like a probable scenario e.g.

change_temp = case season
                when :summer then change { temp }.from(0).to(20)
                when :winter then change { temp }.from(20).to(0)
              end

expect { advance_clock }.to change_temp

Comment on lines +234 to +238
specify do
case condition
when change { obj.foo }.from(1).to(2) then something
end
end
Copy link
Member

Choose a reason for hiding this comment

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

Does it make sense to simplify the cop to only detect cases when a matcher follows the expect?

@pirj
Copy link
Member

pirj commented Feb 21, 2026

Looks very good and userful overall. My comments here are mostly to suggest narrowing down the scope to detecting just the basic case. I have no objections to merging as is as well if you think weird use cases can cause false offences.

@ydakuka
Copy link
Author

ydakuka commented Feb 21, 2026

Thank you for your comments! I’ll take them into account and make the necessary updates as soon as possible.

@pirj
Copy link
Member

pirj commented Feb 21, 2026

rubocop --only RSpec/DiscardedMatcher ../real-world-rspec

Legit offence (source):

/Users/pirj/source/real-world-rspec/gitlabhq/spec/services/event_create_service_spec.rb:186:9: C: RSpec/DiscardedMatcher: The result of change is not used. Did you mean to chain it with .and?
        change { Event.approved_action.where(target: merge_request).count }.by(1)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

False positive? (source)

/Users/pirj/source/real-world-rspec/cartodb/spec/gears/carto_gears_api/users_service_spec.rb:59:17: C: RSpec/DiscardedMatcher: The result of change is not used. Did you mean to chain it with .and?
          }.to (change { @user.reload.crypted_password })
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

/Users/pirj/source/real-world-rspec/mastodon/spec/models/admin/account_action_spec.rb:86:14: C: RSpec/DiscardedMatcher: The result of change is not used. Did you mean to chain it with .and?
        .to (change(Admin::ActionLog.where(action: type), :count).by 1)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

/Users/pirj/source/real-world-rspec/cartodb/spec/gears/carto_gears_api/users_service_spec.rb:65:17: C: RSpec/DiscardedMatcher: The result of change is not used. Did you mean to chain it with .and?
          }.to (change { @user.reload.last_password_change_date })
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

I believe it's similar to the last year's story from RubyWeekly about a whitespace betweeen the method name and parenthesis. The AST is different. But for RSpec, there's no difference, is there?

Those look like false positives (source):

/Users/pirj/source/real-world-rspec/gitlabhq/gems/gitlab-housekeeper/spec/gitlab/housekeeper/change_spec.rb:75:7: C: RSpec/DiscardedMatcher: The result of change is not used. Did you mean to chain it with .and?
      change.non_housekeeper_changes = [:code]
      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
/Users/pirj/source/real-world-rspec/gitlabhq/gems/gitlab-housekeeper/spec/gitlab/housekeeper/change_spec.rb:80:7: C: RSpec/DiscardedMatcher: The result of change is not used. Did you mean to chain it with .and?
      change.non_housekeeper_changes = [:title]
      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
/Users/pirj/source/real-world-rspec/gitlabhq/gems/gitlab-housekeeper/spec/gitlab/housekeeper/change_spec.rb:87:7: C: RSpec/DiscardedMatcher: The result of change is not used. Did you mean to chain it with .and?
      change.non_housekeeper_changes = [:code, :approvals]
      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
/Users/pirj/source/real-world-rspec/gitlabhq/gems/gitlab-housekeeper/spec/gitlab/housekeeper/change_spec.rb:92:7: C: RSpec/DiscardedMatcher: The result of change is not used. Did you mean to chain it with .and?
      change.non_housekeeper_changes = [:code, :title]
      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

@ydakuka ydakuka force-pushed the add-new-rspec-discarded-matcher branch from 680ff12 to 5d7a24f Compare February 28, 2026 21:12
@ydakuka ydakuka force-pushed the add-new-rspec-discarded-matcher branch from 5d7a24f to 68bfe98 Compare February 28, 2026 21:19
@ydakuka
Copy link
Author

ydakuka commented Feb 28, 2026

I’ve addressed all comments, added a few follow-up commits (kept them unsquashed for now), and validated the cop against real-world-rspec/ and real-world-rails/. Could you please take another look when you have time?

Copy link
Member

@pirj pirj left a comment

Choose a reason for hiding this comment

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

I've run the cop against real-world-rspec (I've updated its submodules with the latest), and the cop detected zero offences. Is that right?

The gitlabhq/spec/services/event_create_service_spec.rb still has the offence.

Comment on lines +275 to +279
specify do
def expect_action
expect { action!(performer: performer, context: context) }
end

Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
specify do
def expect_action
expect { action!(performer: performer, context: context) }
end
def expect_action
expect { action!(performer: performer, context: context) }
end
specify do

@ydakuka
Copy link
Author

ydakuka commented Mar 1, 2026

I've run the cop against real-world-rspec (I've updated its submodules with the latest), and the cop detected zero offences. Is that right?

The gitlabhq/spec/services/event_create_service_spec.rb still has the offence.

As you mentioned above, detecting that offense is not the responsibility of this cop. Therefore, I only take into account code that already contains expect / is_expected with to/ not_to/ to_not. Probably I need to add more test examples to demonstrate variety.

$ RUBOCOP_TARGET_RUBY_VERSION=3.4 rubocop ../real-world-rspec/gitlabhq/spec/services/event_create_service_spec.rb  -c /dev/null --plugin rubocop-rspec --only RSpec/NoExpectationExample

Offenses:

/home/ydakuka/Work/real-world-rspec/gitlabhq/spec/services/event_create_service_spec.rb:183:7: C: RSpec/NoExpectationExample: No expectation found in this example.
      it 'creates new event' do ...
      ^^^^^^^^^^^^^^^^^^^^^^^^^

1 file inspected, 1 offense detected

I also checked the cop against the examples from the issue description, and it works for me.

Copy link
Member

@pirj pirj left a comment

Choose a reason for hiding this comment

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

Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unexpected behavior when using multiple change matchers without .and

2 participants