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

Allow chained comparisons #21

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

povilasjurcys
Copy link

@povilasjurcys povilasjurcys commented Apr 26, 2023

Describe the change

This PR allows setting at_most(x).at_least(x) without overriding previously defined expectations.

before the change:

expect { 2.times.map(&:to_s) }
  .to be_faster_then { 20.times.map(&:to_s) } # ratio is 10.0
  .at_least(99).times
  .at_most(100).times # PASSES because we check only `at_most` value

after the change

expect { 2.times.map(&:to_s) }
  .to be_faster_then { 20.times.map(&:to_s) } # ratio is 10.0
  .at_least(99).times
  .at_most(100).times # FAILS because we check `at_least` and  `at_most` values

Why are we doing this?

When testing performance with a comparison matcher it's almost impossible to set an exact value - each machine will perform a bit differently. But we can set a range where we expect the comparison to fall. Sadly it's hard to do so in an intuitive way as it's not possible to set minimum and maximum count for a single case. Having the possibility to set min and max times count would prevent from false-positives and allow us to better notice changes in performance,

Benefits

  • More intuitive expressions;
  • Lower risk of false-positives;

Drawbacks

Possible drawbacks applying this change: 🤷

Requirements

  • Tests written & passing locally?
  • Code style checked?
  • Rebased with master branch?
  • Documentation updated?
  • Changelog updated?

if @comparison_type == :faster
1 < @ratio && @ratio < @count
1 < @ratio && @ratio < count

Choose a reason for hiding this comment

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

Style/YodaCondition: Reverse the order of the operands 1 < @ratio.

end

def expected_count_matchers
@expected_count_matchers.empty? ? DEFAULT_EXPECTED_COUNT_MATCHERS : @expected_count_matchers

Choose a reason for hiding this comment

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

Metrics/LineLength: Line is too long. [102/80]

expected_count_matchers
.select { |_, count| count != 1 }
.map { |type, count| "by #{type} #{count} times" }
.join(' and ')

Choose a reason for hiding this comment

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

Layout/DotPosition: Place the . on the previous line, together with the method call receiver.
Style/StringLiterals: Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.

def comparison_description
expected_count_matchers
.select { |_, count| count != 1 }
.map { |type, count| "by #{type} #{count} times" }

Choose a reason for hiding this comment

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

Layout/DotPosition: Place the . on the previous line, together with the method call receiver.

@@ -162,6 +156,13 @@ def failure_reason

private

def comparison_description
expected_count_matchers
.select { |_, count| count != 1 }

Choose a reason for hiding this comment

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

Layout/DotPosition: Place the . on the previous line, together with the method call receiver.


it "fails if the block performance ratio is lower then indicated by at_most" do
expect {
expect {

Choose a reason for hiding this comment

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

Style/BlockDelimiters: Avoid using {...} for multi-line blocks.

end

it "fails if the block performance ratio is lower then indicated by at_most" do
expect {

Choose a reason for hiding this comment

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

Style/BlockDelimiters: Avoid using {...} for multi-line blocks.

}.to raise_error(/expected given block to perform faster than comparison block by at_least \d+ times and by at_most \d+ times, but performed faster by \d+.\d+ times/)
end

it "fails if the block performance ratio is lower then indicated by at_most" do

Choose a reason for hiding this comment

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

Metrics/LineLength: Line is too long. [85/80]

expect { 1 << 1 }
.to perform_faster_than { 'x' * 10 * 1024 }
.at_least(2).at_most(15).times
}.to raise_error(/expected given block to perform faster than comparison block by at_least \d+ times and by at_most \d+ times, but performed faster by \d+.\d+ times/)

Choose a reason for hiding this comment

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

Metrics/LineLength: Line is too long. [174/80]

expect {
expect { 1 << 1 }
.to perform_faster_than { 'x' * 10 * 1024 }
.at_least(2).at_most(15).times

Choose a reason for hiding this comment

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

Layout/DotPosition: Place the . on the previous line, together with the method call receiver.

@povilasjurcys
Copy link
Author

@piotrmurach , not sure what's the proper way to deal with rubocop issues. Seems like the other code in the file is written ignoring them so I tried to keep the code consistent

@povilasjurcys
Copy link
Author

@piotrmurach, what should I do in order to merge this PR?

@pboling
Copy link

pboling commented Mar 5, 2024

This is awesome! Ping @piotrmurach

@pboling
Copy link

pboling commented Jul 29, 2024

@piotrmurach Please consider this!

@piotrmurach
Copy link
Owner

Hi @povilasjurcys,

Thank your contribution to rspec-benchmark.

The drawbacks section is important to me. It tells me how thoroughly a feature has been considered before I review anything. There are always drawbacks, even considering increased complexity and maintenance. Further, to add to the considerations:

  • The current implementation will allow incompatible chaining like at_most(...).exactly(...).
  • The chaining is unlimited, so at_most(...).at_least(...).at_most(...) will work.
  • I wonder whether we want to allow setting an upper limit before lower, as in, at_most(...).at_least(...).
  • Potentially, a different matcher could work better like be_between, in_range, to_span or something similar.

Most importantly, is it true that we reduce the risk of false positives by allowing chaining? I mean, when we use, for example, the at_least expectation, we want to prevent regression. My intention wasn't to prevent improvements to the speed profile. A given function may get faster for various reasons such as Ruby update. Is it desirable to fail in that instance? I'd argue that setting both lower and upper limits makes it more brittle. I added at_most as the 'reverse' of the at_least matcher to set the margin for the asymptotic behaviour depending on the comparison applied.

@pboling I appreciate your enthusiasm for this feature. Emojis are fine, I appreciate the feedback. However, please bear in mind that my time is limited and my priorities in open source do not necessarily align with what others want at a given moment. I have a lot of gems to maintain - nearly 70. If you wonder how you can help push things forward. You can do so by reviewing the patch code and providing a critical evaluation of the approach.

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.

5 participants