rubocop-rspec icon indicating copy to clipboard operation
rubocop-rspec copied to clipboard

`RSpec/NoExpectationExample` false positives

Open boris-petrov opened this issue 2 years ago • 9 comments

scenario 'test' do
  method
end

private

def method
  expect(1).to eq(1)
end

This gives RSpec/NoExpectationExample but it shouldn't. :)

boris-petrov avatar Sep 12 '22 20:09 boris-petrov

Similar problem then expect in after block

Something like this:

RSpec.describe do
  after do
    expect('File exists').not_to be_nil
  end

  it 'foo' do
    p 'crete_file'
  end

  it 'bar' do
    p 'crete_file another way'
  end
end

ShockwaveNN avatar Sep 13 '22 08:09 ShockwaveNN

It also does not accept Capybara::Node::Matchers#assert_text as an assertion in an example like:

    it "is not authorized" do
      assert_text("You are not authorized to perform this action.")
    end

annaswims avatar Sep 13 '22 13:09 annaswims

A lot of edge cases for single cop, maybe it should be disabled by default

ShockwaveNN avatar Sep 13 '22 13:09 ShockwaveNN

@annaswims you can add assert_text (and other Capybara assertions) in the language config, so it counts it as an expectation

Darhazer avatar Sep 13 '22 13:09 Darhazer

Thanks! I missed the docs

annaswims avatar Sep 16 '22 15:09 annaswims

I think we have to account for the expectations in hooks that are in scope. We have the ExpectInHook that would disallow such usage, but it would be frustrating for users to disable that cop just to have NoExpectationExample complain again.

Maybe we also should add the capybara expectations by default to the language (perhaps behind a config option). I'm on a fense for this one, as it would affect things like MultipleExpectations - perhaps the style for capybara scenarios is a bit different.

@rubocop/rubocop-rspec WDYT?

Darhazer avatar Sep 19 '22 11:09 Darhazer

I think this would be much easier to check with dynamic analysis that with static analysis. @pirj Is this something that might be built into RSpec itself one day?

bquorning avatar Sep 19 '22 13:09 bquorning

No, it's not possible (to do this reliably) in RSpec, too, since rspec-mocks/rspec-expectations are not the only way of making expectations, e.g. assert_nil being one I frequently see. There was a discussion about this somewhere in RSpec issue tracker, but I can't quickly find it.

pirj avatar Sep 20 '22 07:09 pirj

I have reviewed a codebase with the cop applied, and now I think that we may add an AllowedPatterns configuration, so people can just whitelist expect_ and assert_ methods without needing to add them to Language. Though having not them in language also forces people to think how to better express them, e.g. creating custom matchers, instead of merely methods that run expectations

Darhazer avatar Sep 20 '22 08:09 Darhazer

My case with sequel-enum_values:

I've got RSpec/NoExpectationExample: No expectation found in this example for such code:

shared_examples(
	'for all enum predicate methods'
) do |predicate_method_names, *expectations|
	predicate_method_names.each do |predicate_method_name|
		describe predicate_method_name do
			subject { item_class.new.public_send(predicate_method_name) }

			## rubocop:disable RSpec/NoExpectationExample
			specify do
				Array(expectations).each do |expectation|
					instance_exec(&expectation)
				end
			end
			## rubocop:enable RSpec/NoExpectationExample
		end
	end
end

I understand, meta-programming is difficult for linters like RuboCop.

But I've also got:

Lint/RedundantCopDisableDirective: Unnecessary disabling of RSpec/NoExpectationExample.

That's non-sense!

> bundle exec rubocop spec/sequel/plugins/enum_values_spec.rb

Inspecting 1 file
W

Offenses:

spec/sequel/plugins/enum_values_spec.rb:167:9: W: [Correctable] Lint/RedundantCopDisableDirective: Unnecessary disabling of RSpec/NoExpectationExample.
							## rubocop:disable RSpec/NoExpectationExample
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
spec/sequel/plugins/enum_values_spec.rb:168:8: C: RSpec/NoExpectationExample: No expectation found in this example.
							specify do ...
       ^^^^^^^^^^

1 file inspected, 2 offenses detected, 1 offense autocorrectable

AlexWayfer avatar Oct 03 '22 20:10 AlexWayfer

Unnecessary disabling of RSpec/NoExpectationExample

This would be better reported to the parent project https://github.com/rubocop/rubocop/issues/

meta-programming is difficult for linters like RuboCop.

Not only. Also for people if its sole purpose is to make things dry rather than more readable.

Looking at the spec:

status_enum_values = %w[created selected canceled]

describe 'predicate_methods option' do
  convert_enum_values_to_predicate_methods = lambda do |enum_values|
    enum_values.map { |enum_value| :"#{enum_value}?" }
  end

  status_enum_predicate_methods =
    convert_enum_values_to_predicate_methods.call status_enum_values

  include_examples 'for all enum predicate methods',
    status_enum_predicate_methods,
    -> { expect(subject).to be false }

leads me to a thought that this can be written as

status_enum_values = %w[created selected canceled]

describe 'predicate_methods option' do
  status_enum_values.each do |status_enum_value|
    subject { item_class.new.public_send("#{status_enum_value}?") }

    it 'returns false' do
      expect(subject).to be(false)
    end
  end
end

Or:

status_enum_values = %w[created selected canceled]

shared_examples 'returns false' do |status_enum_value|
  subject { item_class.new.public_send("#{status_enum_value}?") }

  it 'returns false' do
    expect(subject).to be(false)
  end
end

status_enum_values.each do |status_enum_value|
  it_behaves_like 'returns false', status_enum_value
end

Am I missing something? Do those options look worse from your perspective, @AlexWayfer ?

pirj avatar Oct 04 '22 08:10 pirj

@annaswims you can add assert_text (and other Capybara assertions) in the language config, so it counts it as an expectation

Adding has_text? to language config doesn't help (I've also try just has_text) in cases like page.has_text?.

texpert avatar Oct 05 '22 18:10 texpert

Can you provide an example of a spec with has_text?, @texpert ?

pirj avatar Oct 06 '22 12:10 pirj

Can you provide an example of a spec with has_text?, @texpert ?

Well, here's one with has_link?, @pirj : - https://github.com/texpert/rails_6_rss_reader/commit/4923144a590cffce414cf0b84d4182dc52cc8c2d

When changing the has_link? to page.has_link?, we get a Rubocop failure:

Offenses:

spec/feauters/home_page_spec.rb:38:9: C: RSpec/NoExpectationExample: No expectation found in this example. (https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/NoExpectationExample)
        it 'has a `New Feed` link' do ...
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

texpert avatar Oct 06 '22 12:10 texpert

        it 'has a `New Feed` link' do
          within('body') do
            has_link?('New Feed', exact: true)
          end
        end

Are you confident that this code is correct, @texpert ? Isn't it a no-op in terms of expectations? Shouldn't it be expect(page).to have_link?('New Feed', exact: true)?

I have no awareness if page is the root page or how within affects that scope, unfortunately, but I'd say that within('body') doesn't do much scoping anyway, does it?

pirj avatar Oct 06 '22 17:10 pirj

I am pretty confident, @pirj - see https://github.com/teamcapybara/capybara#querying and https://github.com/teamcapybara/capybara#scoping

Capybara's matchers are more concise and readable - https://rubydoc.info/github/teamcapybara/capybara/master/Capybara/Node/Matchers

Also, as a main reason for using Capybara's matchers, they are adhering to a default waiting behaviour - https://github.com/teamcapybara/capybara#asynchronous-javascript-ajax-and-friends.

texpert avatar Oct 06 '22 20:10 texpert

As I can see from the source of e.g. has_link?,

      def has_link?(locator = nil, **options, &optional_filter_block)
        has_selector?(:link, locator, **options, &optional_filter_block)
      end
      def has_selector?(*args, **options, &optional_filter_block)
        make_predicate(options) { assert_selector(*args, options, &optional_filter_block) }
      end
      def make_predicate(options)
        options[:wait] = 0 unless options.key?(:wait) || session_options.predicates_wait
        yield
      rescue Capybara::ExpectationNotMet
        false
      end

just returns false that is not sufficient for RSpec to consider the example failed.

Won't

        it 'has a `New Feed` link' do
          within('body') do
            has_link?('New Feed', exact: true)
          end
        end

be equivalent to:

        it 'has a `New Feed` link' do
          within('body') do
            false
          end
        end

?

As I recall how RSpec predicate matchers work, expect(page).to have_link(...) is a just another way of writing expect(page.has_link?(...)).to be(true). But omitting expect(page) effectively means no expectation is made.

pirj avatar Oct 06 '22 22:10 pirj

Personally, I follow the recommendation to use helper methods that is supported by the Effective Testing with RSpec book.

However, since for static checking it creates complications, do you think we can all agree that if something is an expectation, we can name it as such?

def expect_low_value
  expect(product.value).to be_between(0, 50)
end

context 'during summer' do
  it 'has low value' do
    expect_low_value
  end
end

In such a way, we can introduce an option with a default to the cop, say, ConsiderHelperMethodsPrefixes: [expect] that would consider such helper methods as expectations.

Eventually, this can become part of the community RSpec style guide if it gets some traction.

How does that sound?

PS My apologies for an attempt to steal the idea, this is what @Darhazer suggests above.

pirj avatar Oct 07 '22 15:10 pirj

As I can see from the source of e.g. has_link?,

      def has_link?(locator = nil, **options, &optional_filter_block)
        has_selector?(:link, locator, **options, &optional_filter_block)
      end
      def has_selector?(*args, **options, &optional_filter_block)
        make_predicate(options) { assert_selector(*args, options, &optional_filter_block) }
      end
      def make_predicate(options)
        options[:wait] = 0 unless options.key?(:wait) || session_options.predicates_wait
        yield
      rescue Capybara::ExpectationNotMet
        false
      end

just returns false that is not sufficient for RSpec to consider the example failed.

Won't

        it 'has a `New Feed` link' do
          within('body') do
            has_link?('New Feed', exact: true)
          end
        end

be equivalent to:

        it 'has a `New Feed` link' do
          within('body') do
            false
          end
        end

?

As I recall how RSpec predicate matchers work, expect(page).to have_link(...) is a just another way of writing expect(page.has_link?(...)).to be(true). But omitting expect(page) effectively means no expectation is made.

Yep, @pirj ,you're right - I don't know why I was thinking these matchers are working also like expectations :(

texpert avatar Oct 07 '22 15:10 texpert

expect in after block

RSpec/ExpectInHook disapproves such style, @ShockwaveNN

pirj avatar Oct 07 '22 15:10 pirj

Does anyone want to send a PR to introduce AllowedPatterns configuration option, so with default expect_ and assert_ as @Darhazer suggests above?

pirj avatar Oct 07 '22 15:10 pirj

If no one plans to send a PR, I will send. How do you like it?

ydah avatar Oct 09 '22 11:10 ydah

There's no better candidate than you, @ydah with so many recent contributions, please go for it.

pirj avatar Oct 09 '22 13:10 pirj