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

Let's change the default for RSpec/PredicateMatcher

Open marcandre opened this issue 5 years ago • 44 comments

RSpec/PredicateMatcher tells me to prefer the first form to the second one:

    context 'given some result' do
      let(:error) { 'Oops, a few things blew up and nothing worked' }

      it 'check with nil?' do
        expect(error.nil?).to be(true)
      end

      it 'check with be_nil' do
        expect(error).to be_nil
      end
    end

This is gravely mistaken. It's longer, it's imo less clear, but much more importantly a failure (like in this example) gives uninformative info:

  1) some example given some result check with nil?
     Failure/Error: expect(error.nil?).to be(true)
     
       expected true
            got false

  2) some example given some result check with be_nil
     Failure/Error: expect(error).to be_nil
     
       expected: nil
            got: "Oops, a few things blew up and nothing worked"

Notice how the second failure is much more informative, showing the unexpected error and all. That is the reason to always use precise matchers.

I don't mind if we keep the functionality for the (imo always bad) style, I don't care if we propose be_nil or eq(nil), but the default should change.

Let's minimize the reasons to curse at RuboCop.

marcandre avatar May 27 '20 01:05 marcandre

We don't have a formal checklist for changing the defaults, but if I had to come up with it, it would be:

  • [ ] make sure to only change defaults on major version updates
  • [ ] check settings for that cop in
    • https://github.com/eliotsykes/real-world-rails
    • https://github.com/jeromedalbert/real-world-ruby-apps
  • [x] check with the style guide https://rspec.rubystyle.guide/ (https://rspec.rubystyle.guide/#predicate-matchers)

It would be really nice to harmonize the defaults with the style guide and to harmonize the style guide with what is really commonly used in those open-source apps. It looks like a task on its own, but before we've done that, we can follow that "simple" checklist.

Would you like to grep through .rubocop.yml settings for PredicateMatcher of real-world-rails/real-world-ruby-apps? Part of them doesn't use RuboCop or RSpec, unfortunately, but some can be used to make a judgememnt on this topic.

pirj avatar May 27 '20 09:05 pirj

Found a mistake in the example, "also good" part:

Strict: true, EnforcedStyle: inflected (default)

# bad
expect(foo.something?).to be_truthy

# good
expect(foo).to be_something

# also good - It checks "true" strictly.
expect(foo).to be(true)

# ^^^ should be
expect(foo.something?).to be(true)

As far as I understand, to follow your proposal we would have to change Strict from true to false, but it seems to accept be_truthy/be_falsey then. Looks very unintuitive. We probably need to rethink the approach unless I'm missing something.

pirj avatar May 27 '20 09:05 pirj

FWIW, I think be_truthy/be_falsey should be forbidden in a different cop. I can think of no circumstance where they should be used.

marcandre avatar Jun 04 '20 04:06 marcandre

be_truthy/be_falsey should be forbidden in a different cop

Completely agree.

image Splitting cops ☝️

be_truthy/be_falsey ... can think of no circumstance where they should be used.

If we are talking about predicates - sure, but there are certain cases when it's better to be less strict about expectations.

In general, we recommend you use the loosest matcher that still specifies the behavior you care about.

  • Effective Testing with RSpec 3

The need can be implied by a weird interface or even some weirdness in the Ruby itself:

0.zero?
# => true
0.nonzero?
#=> nil
1.zero?
#=> false
1.nonzero?
#=> 1

https://metaredux.com/posts/2019/06/11/weird-ruby-zeroing-in-on-a-couple-of-numeric-predicates.html

pirj avatar Jun 04 '20 09:06 pirj

The need can be implied by a weird interface or even some weirdness in the Ruby itself:

0.zero?
# => true
0.nonzero?
#=> nil
1.zero?
#=> false
1.nonzero?
#=> 1

The "weirdness" of nonzero? is misconceived. Probably simply because of the "?". It would also a horrible use of be_truthy (that it returns the receiver and not just a truthy value is paramount in (a <=> b).nonzero? || (c <=> d) for example) or be_falsy (it never returns false).

there are certain cases when it's better to be less strict about expectations.

Legitimate ones? I'll still need to see one be convinced 😸

marcandre avatar Jun 04 '20 12:06 marcandre

Hold on for a second. What are the PredicateMatcher configuration where it tells the prefer expect(error.nil?).to be(true) over expect(error).to be_nil? Is it the default setting?

With the default, which is Strict: true, EnforcedStyle: inflected (default) I don't get any offences in your code.

With EnforcedStyle: explicit there's "Prefer using nil? over be_nil matcher" (no matter what Strict is set to. With EnforcedStyle: inflected Strict: false there's "Prefer using be_nil matcher over nil?". It's strange, I would expect it to raise an offence with Strict: true as well. Might be a bug.

So are you talking about this bug, or do you propose to retire/deprecate the explicit option altogether? This sounds different from this ticket's title.

Regarding be_falsey/be_truthy there are quite some examples in RSpec's code itself [be_falsey, be_truthy]. Are you up to make those specs better as an example of your point?

pirj avatar Jun 04 '20 13:06 pirj

The reason these are in rspec is because these matchers used to be called be_false and be_true which was very confusing as most people thought they meant be(false) and be(true) When these were (rightfully) renamed, they did the easy thing: https://github.com/rspec/rspec-core/commit/eb5bb45a09

I don't think I needed a PR as an example of my point, but here it is nevertheless: https://github.com/rspec/rspec-core/pull/2736

marcandre avatar Jun 04 '20 17:06 marcandre

I'm sorry I didn't check properly initially, and it's good that we've researched this. So, from my point of view:

  1. The code example expect(foo).to be(true) is incorrect
  2. For the code in the description, the cop with default settings should have raised "Prefer using be_nil matcher over nil?" but it didn't
  3. We may consider splitting the cop into one that takes care of predicates, and another that keeps an eye on be_falsey/be_falsy/be_truthy (check related #244, #176 and #693) - it would be nice to clean this all up with a massive sweep

Do you think there is anything else actionable? Are you happy with the current default settings?

PS I deeply respect your persistence and strong opinions.

pirj avatar Jun 05 '20 11:06 pirj

1. The code example `expect(foo).to be(true)` is incorrect

Yes, I'm sure expect(foo.something?) was meant.

2. For the code in the description, the cop with default settings should have raised "Prefer using be_nil matcher over nil?" but it didn't

Should prefer foo).to be_nil over foo.nil?).to be(true), without a doubt.

3. We may consider splitting the cop into one that takes care of predicates, and another that keeps an eye on `be_falsey`/`be_falsy`/`be_truthy` (check related #244, #176 and #693) - it would be nice to clean this all up with a massive sweep

Absolutely. I can imagine many people wanting to avoid be_truthy but being ok with predicate?).to be(true)

Do you think there is anything else actionable? Are you happy with the current default settings?

Besides enforcing expect(predicate?).to be(true) => to be_predicate, I'm sure there are many other uses of be(true) that are "incorrect" in my book. The vast majority of specs with be(true) can be improved; something the API is at fault (if one defines foo instead of foo?), or a better matcher can be used.

There are very few legitimate uses of be(true) and be(false) I can think of:

expect { foo }.to change { bar? }.to be(true)
expect(foo?(args)).to be(true)

subject(:enabled?)
it { is_expected.to be(true) }

Note that even foo?(arg) should probably be turned into a matcher. We have a lot of:

expect(described_class.match_path?('c{at,ub}s', 'cubs')).to be(true)
# should be
subject { described_class }
is_expected to.match_path('c{at,ub}s', 'cubs')

Edit: Actually I just realized that automagical predicate matchers accept arguments. So be_match_path would work, if a bit odd,

Other builtin matchers

I didn't check if there's a cop for this, but I see a lot of cases where builtin matchers are not used. eg.:

expect(word_regexp.is_a?(::Regexp)).to be(true)
# should be
expect(word_regexp).to be_a(::Regexp)

Arrays are a typical example:

expect(ary.size).to be(3)
# should be
expect(ary).to have(3).items

What's worse is that then the elements are tested one after the other:

expect(ary.size).to be(3)
expect(ary[0]).to some_matcher
expect(ary[2]).to some_other_matcher
# should be
expect(ary).to match_exactly [
  some_matcher,
  anything,
  some_other_matcher,
] # Note: size checked implicitly

When that fails, we have the whole array we can compare.

Yes, I put the trailing comma out of spite, that's another default I disagree with 😆

PS I deeply respect your persistence and strong opinions.

Thank, I hope I don't come out too strong to others.

marcandre avatar Jun 05 '20 20:06 marcandre

I'm working on a pull request (not pushed yet) to address the issues of the PredicateMatcher.

  1. fixed

For the code in the description, the cop with default settings should have raised "Prefer using be_nil matcher over nil?" but it didn't.

Should prefer foo).to be_nil over foo.nil?).to be(true), without a doubt.

It turns out that the default Strict: true, EnforcedStyle: inflected (default) means that a expect(foo).to be_something style is generally preferred, but "strict" checks, e.g. be(true), eq(true) etc are tolerated:

# also good - It checks "true" strictly.
expect(foo.something?).to be(true)

This goes against https://rspec.rubystyle.guide/#predicate-matchers that states "strict" usages as a bad example:

# bad
expect(subject.published?).to be true

To add even more controversy, quoting Effective Testing with RSpec again:

In general, we recommend you use the loosest matcher that still specifies the behaviour you care about.

So from my understanding, "strict" is worse than be_falsey/be_truthy, as it's excessively specific when applied to predicate matchers, e.g. if they happen to return nil which can be considered as "not defined, but presumably false".

To be fair, be_truthy is mentioned in this guideline https://rspec.rubystyle.guide/#be-matcher as one of the allowed replacements for be matcher without arguments passed. not_to be_nil arguably sounds better, but it misses the case when the SUT is false. And it's less generic than be_truthy.

I guess the justified predicate-look-alike methods like nonzero? are scarce (unjustified example).

  1. I'm going to leave the above problem with "strict" ☝️ as-is, probably providing different defaults for separated cops basing on real-world-ruby/real-world-rails usage statistics. Since RuboCop 1.0 is about to happen, we may bump our major version too, and shamelessly set the new defaults.

To be continued...

pirj avatar Jun 13 '20 18:06 pirj

In general, we recommend you use the loosest matcher that still specifies the behaviour you care about.

Right, I agree with that quote, but only in the context of applications. For gems I believe that the behavior we care about is the exact behavior so specs should be quite strict.

marcandre avatar Jun 13 '20 18:06 marcandre

Just got more autocorrections for:

expect(something).to be_a(SomeClass)
# corrected to
expect(something.is_a?(SomeClass)).to be(true)

That's also equally bad, for the same reasons as the rest.

marcandre avatar Jun 14 '20 04:06 marcandre

In general, we recommend you use the loosest matcher that still specifies the behaviour you care about.

Right, I agree with that quote, but only in the context of applications. For gems I believe that the behavior we care about is the exact behavior so specs should be quite strict.

Even though I agree that gems should be more specific in regards to their public API, they may choose to be more relaxed when testing private APIs. Anyway, rubocop-rspec is not only for gems, it's also for applications.

pirj avatar Jun 14 '20 07:06 pirj

corrected to

expect(something.is_a?(SomeClass)).to be(true)

That's also equally bad, for the same reasons as the rest.

I'm not sure I understand. Do you think is_a? should be yet another exception to the explicit style? It's getting pretty complicated.

pirj avatar Jun 14 '20 07:06 pirj

may choose to be more relaxed when testing private APIs.

They may. I'll disagree with the fact that they are testing private APIs 😆.

marcandre avatar Jun 14 '20 07:06 marcandre

I'm not sure I understand. Do you think is_a? should be yet another exception to the explicit style? It's getting pretty complicated.

Sorry, I haven't looked into the cop, or its settings. I don't know what the explicit style is, or what it should be.

All I know is that expect(something).to be_a(SomeClass) should be the preferred form, which is not the case currently as RuboCop enforces the other form.

To summarize, my position is that, by default (or at least in RuboCop):

  • be_falsey / be truthy => never
  • be(true) / be(false) => use predicate matchers instead
  • most builtin matchers (like be_a, match_array, have(n).items, I'm of course excluding be_falsey / be_truthy) should always be preferred.

I have no informed opinion as to what the settings should be named / how the cops should be split. Just that the defaults are horribly wrong for RuboCop.

marcandre avatar Jun 14 '20 07:06 marcandre

To avoid any ambiguity, I didn't mean testing private methods. I meant private API that is callable but not by regular gem's consumers. E.g. RuboCop's ConfigLoader or ConfigValidator. I don't want to dive into unit vs acceptance discussion, usually, it's a balance of the two, and that means some generic expectations can take place even in gem testing code pretty legitimately.

pirj avatar Jun 14 '20 07:06 pirj

I actually disagree with the quote:

In general, we recommend you use the loosest matcher that still specifies the behaviour you care about.

I think it's best to specify things as tightly as possible. It's too easy for subtle bugs to sneak into code when we aren't precise in what we expect. That being said, I do find the be_ syntax more readable. I wish it was stricter in terms of checking for true and false rather than truthy and falsey.

My thinking on it at this point is basically that we should be explicit if it is the predicate method under test. This ensures the method returns true or false and not just something truthy or nil. This is especially important if we're serializing something to send over the wire. And I would like to use the be_ matchers otherwise:

# good
describe '#foo?' do
  it 'returns false' do
    expect(thing.foo?).to be(false)
  end

  it 'returns true when fooed' do
    thing.foo!

    expect(thing.foo?).to be(true)
  end
end

describe '#foo!' do
  it 'makes it foo' do
    thing.foo!

    expect(thing).to be_foo
  end
end

# bad
describe '#foo?' do
  it 'returns false' do
    expect(thing).not_to be_foo
  end

  it 'returns true when fooed' do
    thing.foo!

    expect(thing).to be_foo
  end
end

describe '#foo!' do
  it 'makes it foo' do
    thing.foo!

    expect(thing.foo?).to be(true)
  end
end

That's a little hard to lint, though, so we generally go with the explicit approach.

mockdeep avatar Jun 14 '20 20:06 mockdeep

I was convinced that expect(thing).to be_foo was checking for == true but it isn't. 🤯 The doc really seem to imply the contrary, and the error messages too!

expected `obj.foo?` to return false, got 42
# or
expected `obj.foo?` to return true, got nil

That's also the behavior I believe it should have.

I'll open an issue. If they agree it's a bug, we'll fix it, otherwise I'll propose a setting for tight predicate matchers that we should turn on.

I still much prefer the error messages with these matchers

marcandre avatar Jun 14 '20 20:06 marcandre

I think it would break a lot of tests if RSpec were to change it. Maybe they could add a configuration for strict_predicates or something.

mockdeep avatar Jun 14 '20 20:06 mockdeep

I think it would break a lot of tests if RSpec were to change it.

I'm very curious about that, but if so it might hide bugs.

Maybe they could add a configuration for strict_predicates or something.

I proposed just that in your issue

marcandre avatar Jun 14 '20 20:06 marcandre

- expect(ary.size).to be(3)
+ expect(ary).to have(3).items

IIRC, have is from rspec-collection_matchers. Out of the box, this works:

- expect(ary.size).to be(3)
+ expect(ary).to have_attributes(size: 3)

but for me personally it looks like a bad replacement.

pirj avatar Jun 16 '20 20:06 pirj

- expect(ary.size).to be(3)
- expect(ary[0]).to some_matcher
- expect(ary[2]).to some_other_matcher
+ expect(ary).to match_array [
+  some_matcher,
+  anything,
+  some_other_matcher,
+ ] # Note: size checked implicitly

This appeals to me and I'm using this a lot with include and have_attributes, but unfortunately, contain_exactly and match_array are order-independent. Using argument matchers with them also induces a problem of combinatorial comparison:

    expect([1, 2, 3]).to match_array([be.>(2), be_even, eq(1)])

In this case, eq works:

expect(ary).to eq([
  some_matcher,
  anything,
  some_other_matcher,
]) # Note: size checked implicitly

pirj avatar Jun 16 '20 20:06 pirj

contain_exactly and match_array are order-independent.

Oh, indeed, my bad. Your example is what I should have written.

marcandre avatar Jun 16 '20 20:06 marcandre

Let me start with splitting the spec file into strict and non-strict, it will pave the way to splitting the cop. We can iteratively spawn new cops or extend existing (and contained!) cops to cover what have discussed above.

pirj avatar Jun 17 '20 09:06 pirj

I've discovered ~three~ two completely valid options for correction of:

    expect([-1,2,3].all? { |x| x.positive? }).to be(true)

The difference is in the failure message. For the original:

     Failure/Error: expect([-1,2,3].all? { |x| x.positive? }).to be(true)
       expected true
            got false
    expect([-1,2,3]).to be_all { |x| x.positive? }
     Failure/Error: expect([-1,2,3]).to be_all { |x| x.positive? }
       expected `[-1, 2, 3].all?` to return true, got false

~2.~

    expect([-1,2,3]).to all be(&:positive?)

it's surprisingly a success. I'll file an idea ticket for a cop and a ticket for RSpec to emit a warning/fail if a block is passed to be.

    expect([-1,2,3]).to all be_positive
     Failure/Error: expect([-1,2,3]).to all be_positive

       expected [-1, 2, 3] to all be positive

          object at index 0 failed to match:
             expected `-1.positive?` to return true, got false

The latter seems to be the most informative.

pirj avatar Jun 17 '20 17:06 pirj

Good catch on the block 👍

expect([-1,2,3]).to all be_positive

Definitely the best 🎉

marcandre avatar Jun 17 '20 19:06 marcandre

To summarize, my position is that, by default (or at least in RuboCop):

* `be_falsey / be truthy` => never

I disagree about be_falsey. There are many situations where be_false would be over-specifying.

I write if !thing not if thing == false because functionally I'm not going to do anything different with a nil vs false. If there is a functional difference between nil and false, the method should be throwing an exception rather than returning nil.

Another example is when the data or code is not entirely under my control. If I'm receiving JSON from an API if it's false today it might be nil tomorrow. I specify it's just false. Similarly, if I'm calling a method in someone else's class gem I do not trust they will be careful about nil vs false. I don't want to have to sanitize every return value with return call_someone_elses_method ? true : false.

Similar for be_truthy. Sometimes I don't care what it is, just that it's true (ie. not false). There may be an argument I should be using something more strict like be_a but sometimes even that is outside the spec's concern.

Perhaps this is a difference between writing application code (be lax in what you receive) and gem code (be strict in what you send).

One thing is clear: be_truthy/be_falsey should be its own cop separate from PredicateMatcher. Over-specifying makes it more likely the cop will be disabled.

schwern avatar Jul 16 '20 20:07 schwern

I write if !thing not if thing == false

Agreed, usually it doesn't matter. But it might, e.g. thing&.something?, or collection.group_by(&:thing).

Perhaps this is a difference between writing application code (be lax in what you receive) and gem code (be strict in what you send).

Possibly, but typically you shouldn't spec what you receive. Note that I wrote "at least for RuboCop"

Another example is when the data or code is not entirely under my control

I'm curious as to why you'd want to have specs for that.

be_truthy/be_falsey should be its own cop

👍

marcandre avatar Jul 16 '20 21:07 marcandre

@pirj have you been able to make progress on this front?

marcandre avatar Jul 24 '20 17:07 marcandre