Skip to content

Commit

Permalink
Implement count constraints for include matcher
Browse files Browse the repository at this point in the history
  • Loading branch information
marcandre committed Mar 17, 2020
1 parent 14b240a commit 7d7374d
Show file tree
Hide file tree
Showing 4 changed files with 222 additions and 31 deletions.
2 changes: 2 additions & 0 deletions Changelog.md
Expand Up @@ -10,6 +10,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
38 changes: 21 additions & 17 deletions features/built_in_matchers/include.feature
Expand Up @@ -4,15 +4,15 @@ 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")

expect([1, 2]).to include(1)
expect([1, 2]).to include(1, 2)
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)
expect([1, 2]).to include(be < 10).at_least(2).times
expect([1, 2]).not_to include(17)
```
Expand Down Expand Up @@ -41,14 +41,14 @@ 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 }
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 +61,42 @@ 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 |
| 19 examples, 8 failures |
| expected [1, 3, 7] to include 4 |
| 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
74 changes: 60 additions & 14 deletions lib/rspec/matchers/built_in/include.rb
@@ -1,35 +1,48 @@
require 'rspec/matchers/built_in/count_expectation'

module RSpec
module Matchers
module BuiltIn
# @api private
# Provides the implementation for `include`.
# Not intended to be instantiated directly.
class Include < BaseMatcher
class Include < BaseMatcher # rubocop:disable Metrics/ClassLength
include CountExpectation

# @private
attr_reader :expecteds

def initialize(*expecteds)
@expecteds = expecteds
super()
end

# @api private
# @return [Boolean]
def matches?(actual)
actual = actual.to_hash if convert_to_hash?(actual)
perform_match(actual) { |v| v }
check_actual?(actual) &&
if check_expected_count?
expected_count_matches?(count_inclusions)
else
perform_match { |v| v }
end
end

# @api private
# @return [Boolean]
def does_not_match?(actual)
actual = actual.to_hash if convert_to_hash?(actual)
perform_match(actual) { |v| !v }
check_actual?(actual) &&
if check_expected_count?
!expected_count_matches?(count_inclusions)
else
perform_match { |v| !v }
end
end

# @api private
# @return [String]
def description
improve_hash_formatting("include#{readable_list_of(expecteds)}")
improve_hash_formatting("include#{readable_list_of(expecteds)}#{count_expectation_description}")
end

# @api private
Expand Down Expand Up @@ -62,12 +75,33 @@ def expected

private

def format_failure_message(preposition)
if actual.respond_to?(:include?)
improve_hash_formatting("expected #{description_of @actual} #{preposition} include#{readable_list_of @divergent_items}")
else
improve_hash_formatting(yield) + ", but it does not respond to `include?`"
def check_actual?(actual)
actual = actual.to_hash if convert_to_hash?(actual)
@actual = actual
@actual.respond_to?(:include?)
end

def check_expected_count?
case
when !has_expected_count?
return false
when expecteds.size != 1
raise NotImplementedError, 'Count constraint supported only when testing for a single value being included'
when actual.is_a?(Hash)
raise NotImplementedError, 'Count constraint on hash keys not implemented'
end
true
end

def format_failure_message(preposition)
msg = if actual.respond_to?(:include?)
"expected #{description_of @actual} #{preposition}" \
" include#{readable_list_of @divergent_items}" \
"#{count_failure_reason('it is included') if has_expected_count?}"
else
"#{yield}, but it does not respond to `include?`"
end
improve_hash_formatting(msg)
end

def readable_list_of(items)
Expand All @@ -79,10 +113,9 @@ def readable_list_of(items)
end
end

def perform_match(actual, &block)
@actual = actual
def perform_match(&block)
@divergent_items = excluded_from_actual(&block)
actual.respond_to?(:include?) && @divergent_items.empty?
@divergent_items.empty?
end

def excluded_from_actual
Expand Down Expand Up @@ -134,6 +167,19 @@ def actual_collection_includes?(expected_item)
actual.any? { |value| values_match?(expected_item, value) }
end

def count_inclusions
@divergent_items = expected
expected_item = expected.first
case actual
when Enumerable
actual.count { |value| values_match?(expected_item, value) }
when String
actual.scan(expected_item).length
else
raise ArgumentError, 'Count constraints are implemented for Enumerable and String values only'
end
end

def diff_would_wrongly_highlight_matched_item?
return false unless actual.is_a?(String) && expected.is_a?(Array)

Expand Down

0 comments on commit 7d7374d

Please sign in to comment.