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 count constraint on include matcher #1168

Merged
merged 2 commits into from Aug 28, 2020
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions Changelog.md
Expand Up @@ -18,6 +18,8 @@ Bug Fixes:

Enhancements:

* Allow `include` matcher to be chained with `once`, `at_least`, etc. for simple cases.
(Marc-André Lafortune, #1168)
* Add an explicit warning when `nil` is passed to `raise_error`. (Phil Pirozhkov, #1143)
* Improve `include` matcher's composability. (Phil Pirozhkov, #1155)
* Mocks expectations can now set a custom failure message.
Expand Down
37 changes: 22 additions & 15 deletions features/built_in_matchers/include.feature
Expand Up @@ -4,7 +4,7 @@ Feature: `include` matcher

```ruby
expect("a string").to include("a")
expect("a string").to include("str")
expect("a string").to include(/a|str/).twice
expect("a string").to include("str", "g")
expect("a string").not_to include("foo")

Expand All @@ -13,6 +13,7 @@ Feature: `include` matcher
expect([1, 2]).to include(a_kind_of(Integer))
expect([1, 2]).to include(be_odd.and be < 10 )
expect([1, 2]).to include(be_odd)
marcandre marked this conversation as resolved.
Show resolved Hide resolved
expect([1, 2]).to include(be < 10).at_least(2).times
expect([1, 2]).not_to include(17)
```

Expand Down Expand Up @@ -41,14 +42,15 @@ Feature: `include` matcher
it { is_expected.to include(1, 3, 7) }
it { is_expected.to include(a_kind_of(Integer)) }
it { is_expected.to include(be_odd.and be < 10) }
it { is_expected.to include(be_odd) }
it { is_expected.to include(be_odd).at_least(:twice) }
it { is_expected.not_to include(be_even) }
it { is_expected.not_to include(17) }
it { is_expected.not_to include(43, 100) }

# deliberate failures
it { is_expected.to include(4) }
it { is_expected.to include(be_even) }
it { is_expected.to include(be_odd).at_most(2).times }
pirj marked this conversation as resolved.
Show resolved Hide resolved
it { is_expected.not_to include(1) }
it { is_expected.not_to include(3) }
it { is_expected.not_to include(7) }
Expand All @@ -61,38 +63,43 @@ Feature: `include` matcher
"""
When I run `rspec array_include_matcher_spec.rb`
Then the output should contain all of these:
| 19 examples, 8 failures |
| expected [1, 3, 7] to include 4 |
| expected [1, 3, 7] not to include 1 |
| expected [1, 3, 7] not to include 3 |
| expected [1, 3, 7] not to include 7 |
| expected [1, 3, 7] not to include 1, 3, and 7 |
| expected [1, 3, 7] to include 9 |
| expected [1, 3, 7] not to include 1 |
| 20 examples, 9 failures |
| expected [1, 3, 7] to include 4 |
| expected [1, 3, 7] to include (be even) |
| expected [1, 3, 7] to include (be odd) at most twice but it is included 3 times |
| expected [1, 3, 7] not to include 1 |
| expected [1, 3, 7] not to include 3 |
| expected [1, 3, 7] not to include 7 |
| expected [1, 3, 7] not to include 1, 3, and 7 |
| expected [1, 3, 7] to include 9 |
| expected [1, 3, 7] not to include 1 |

Scenario: string usage
Given a file named "string_include_matcher_spec.rb" with:
"""ruby
RSpec.describe "a string" do
it { is_expected.to include("str") }
it { is_expected.to include("a", "str", "ng") }
it { is_expected.to include(/a|str/).twice }
it { is_expected.not_to include("foo") }
it { is_expected.not_to include("foo", "bar") }

# deliberate failures
it { is_expected.to include("foo") }
it { is_expected.not_to include("str") }
it { is_expected.to include("str").at_least(:twice) }
it { is_expected.to include("str", "foo") }
it { is_expected.not_to include("str", "foo") }
end
"""
When I run `rspec string_include_matcher_spec.rb`
Then the output should contain all of these:
| 8 examples, 4 failures |
| expected "a string" to include "foo" |
| expected "a string" not to include "str" |
| expected "a string" to include "foo" |
| expected "a string" not to include "str" |
| 10 examples, 5 failures |
| expected "a string" to include "foo" |
| expected "a string" not to include "str" |
| expected "a string" to include "str" at least twice but it is included once |
| expected "a string" to include "foo" |
| expected "a string" not to include "str" |

Scenario: hash usage
Given a file named "hash_include_matcher_spec.rb" with:
Expand Down
169 changes: 169 additions & 0 deletions lib/rspec/matchers/built_in/count_expectation.rb
@@ -0,0 +1,169 @@
module RSpec
module Matchers
module BuiltIn
# @api private
marcandre marked this conversation as resolved.
Show resolved Hide resolved
# Asbtract class to implement `once`, `at_least` and other
# count constraints.
module CountExpectation
# @api public
# Specifies that the method is expected to match once.
def once
exactly(1)
end

# @api public
# Specifies that the method is expected to match twice.
def twice
exactly(2)
end

# @api public
# Specifies that the method is expected to match thrice.
def thrice
exactly(3)
end

# @api public
# Specifies that the method is expected to match the given number of times.
def exactly(number)
set_expected_count(:==, number)
self
end

# @api public
# Specifies the maximum number of times the method is expected to match
def at_most(number)
set_expected_count(:<=, number)
self
end

# @api public
# Specifies the minimum number of times the method is expected to match
def at_least(number)
set_expected_count(:>=, number)
self
end

# @api public
# No-op. Provides syntactic sugar.
def times
self
end

protected
# @api private
attr_reader :count_expectation_type, :expected_count
marcandre marked this conversation as resolved.
Show resolved Hide resolved

private

if RUBY_VERSION.to_f > 1.8
def cover?(count, number)
count.cover?(number)
end
else
def cover?(count, number)
number >= count.first && number <= count.last
end
end

def expected_count_matches?(actual_count)
@actual_count = actual_count
return @actual_count > 0 unless count_expectation_type
return cover?(expected_count, actual_count) if count_expectation_type == :<=>

@actual_count.__send__(count_expectation_type, expected_count)
end

def has_expected_count?
!!count_expectation_type
end

def set_expected_count(relativity, n)
raise_unsupported_count_expectation if unsupported_count_expectation?(relativity)

count = count_constraint_to_number(n)

if count_expectation_type == :<= && relativity == :>=
raise_impossible_count_expectation(count) if count > expected_count
@count_expectation_type = :<=>
@expected_count = count..expected_count
elsif count_expectation_type == :>= && relativity == :<=
raise_impossible_count_expectation(count) if count < expected_count
@count_expectation_type = :<=>
@expected_count = expected_count..count
else
@count_expectation_type = relativity
@expected_count = count
end
end

def raise_impossible_count_expectation(count)
text =
case count_expectation_type
when :<= then "at_least(#{count}).at_most(#{expected_count})"
when :>= then "at_least(#{expected_count}).at_most(#{count})"
end
raise ArgumentError, "The constraint #{text} is not possible"
end

def raise_unsupported_count_expectation
text =
case count_expectation_type
when :<= then "at_least"
when :>= then "at_most"
when :<=> then "at_least/at_most combination"
else "count"
end
raise ArgumentError, "Multiple #{text} constraints are not supported"
end

def count_constraint_to_number(n)
case n
when Numeric then n
when :once then 1
when :twice then 2
when :thrice then 3
else
raise ArgumentError, "Expected a number, :once, :twice or :thrice," \
" but got #{n}"
end
end

def unsupported_count_expectation?(relativity)
return true if count_expectation_type == :==
return true if count_expectation_type == :<=>
(count_expectation_type == :<= && relativity == :<=) ||
(count_expectation_type == :>= && relativity == :>=)
marcandre marked this conversation as resolved.
Show resolved Hide resolved
end

def count_expectation_description
"#{human_readable_expectation_type}#{human_readable_count(expected_count)}"
end

def count_failure_reason(action)
"#{count_expectation_description}" \
" but #{action}#{human_readable_count(@actual_count)}"
end

def human_readable_expectation_type
case count_expectation_type
when :<= then ' at most'
when :>= then ' at least'
when :<=> then ' between'
else ''
end
end

def human_readable_count(count)
case count
when Range then " #{count.first} and #{count.last} times"
when nil then ''
when 1 then ' once'
when 2 then ' twice'
else " #{count} times"
end
end
end
end
end
end