From 6c8e7c351b612da3507057e969d88ebde884ee58 Mon Sep 17 00:00:00 2001 From: Marc-Andre Lafortune Date: Sun, 15 Mar 2020 21:39:29 -0400 Subject: [PATCH 1/2] Make count expectation of yield_control generic --- .../matchers/built_in/count_expectation.rb | 169 ++++++++++++++++++ lib/rspec/matchers/built_in/yield.rb | 154 +--------------- 2 files changed, 176 insertions(+), 147 deletions(-) create mode 100644 lib/rspec/matchers/built_in/count_expectation.rb diff --git a/lib/rspec/matchers/built_in/count_expectation.rb b/lib/rspec/matchers/built_in/count_expectation.rb new file mode 100644 index 000000000..32272abf6 --- /dev/null +++ b/lib/rspec/matchers/built_in/count_expectation.rb @@ -0,0 +1,169 @@ +module RSpec + module Matchers + module BuiltIn + # @api private + # 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 + + 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 == :>=) + 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 diff --git a/lib/rspec/matchers/built_in/yield.rb b/lib/rspec/matchers/built_in/yield.rb index ecc87deb0..9ca6b1b47 100644 --- a/lib/rspec/matchers/built_in/yield.rb +++ b/lib/rspec/matchers/built_in/yield.rb @@ -1,3 +1,5 @@ +require 'rspec/matchers/built_in/count_expectation' + RSpec::Support.require_rspec_support 'method_signature_verifier' module RSpec @@ -96,77 +98,13 @@ def assert_valid_expect_block! # @api private # Provides the implementation for `yield_control`. # Not intended to be instantiated directly. - class YieldControl < BaseMatcher # rubocop:disable ClassLength - def initialize - @expectation_type = @expected_yields_count = nil - end - - # @api public - # Specifies that the method is expected to yield once. - def once - exactly(1) - self - end - - # @api public - # Specifies that the method is expected to yield twice. - def twice - exactly(2) - self - end - - # @api public - # Specifies that the method is expected to yield thrice. - def thrice - exactly(3) - self - end - - # @api public - # Specifies that the method is expected to yield the given number of times. - def exactly(number) - set_expected_yields_count(:==, number) - self - end - - # @api public - # Specifies the maximum number of times the method is expected to yield - def at_most(number) - set_expected_yields_count(:<=, number) - self - end - - # @api public - # Specifies the minimum number of times the method is expected to yield - def at_least(number) - set_expected_yields_count(:>=, number) - self - end - - # @api public - # No-op. Provides syntactic sugar. - def times - self - end - - 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 - + class YieldControl < BaseMatcher + include CountExpectation # @private def matches?(block) @probe = YieldProbe.probe(block) return false unless @probe.has_block? - return @probe.num_yields > 0 unless @expectation_type - return cover?(@expected_yields_count, @probe.num_yields) if @expectation_type == :<=> - - @probe.num_yields.__send__(@expectation_type, @expected_yields_count) + expected_count_matches?(@probe.num_yields) end # @private @@ -193,88 +131,10 @@ def supports_block_expectations? private - def set_expected_yields_count(relativity, n) - raise_unsupported_yield_expectation if unsupported_yield_expectation?(relativity) - - count = count_constraint_to_number(n) - - if @expectation_type == :<= && relativity == :>= - raise_impossible_yield_expectation(count) if count > @expected_yields_count - @expectation_type = :<=> - @expected_yields_count = count..@expected_yields_count - elsif @expectation_type == :>= && relativity == :<= - raise_impossible_yield_expectation(count) if count < @expected_yields_count - @expectation_type = :<=> - @expected_yields_count = @expected_yields_count..count - else - @expectation_type = relativity - @expected_yields_count = count - end - end - - def raise_impossible_yield_expectation(count) - text = - case @expectation_type - when :<= then "at_least(#{count}).at_most(#{@expected_yields_count})" - when :>= then "at_least(#{@expected_yields_count}).at_most(#{count})" - end - raise ArgumentError, "The constraint #{text} is not possible" - end - - def raise_unsupported_yield_expectation - text = - case @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 unsupported_yield_expectation?(relativity) - return true if @expectation_type == :== - return true if @expectation_type == :<=> - (@expectation_type == :<= && relativity == :<=) || (@expectation_type == :>= && relativity == :>=) - 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 failure_reason return ' but was not a block' unless @probe.has_block? - return "#{human_readable_expectation_type}#{human_readable_count(@expected_yields_count)} but did not yield" if @probe.num_yields.zero? - - "#{human_readable_expectation_type}#{human_readable_count(@expected_yields_count)}" \ - " but yielded#{human_readable_count(@probe.num_yields)}" - end - - def human_readable_expectation_type - case @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 + return "#{count_expectation_description} but did not yield" if @probe.num_yields == 0 + count_failure_reason('yielded') end end From d7082704f0b248da83debfd3d45cfa3b9faa1649 Mon Sep 17 00:00:00 2001 From: Marc-Andre Lafortune Date: Mon, 16 Mar 2020 23:44:34 -0400 Subject: [PATCH 2/2] Implement count constraints for `include` matcher --- Changelog.md | 2 + features/built_in_matchers/include.feature | 37 +++-- lib/rspec/matchers/built_in/include.rb | 82 +++++++++-- spec/rspec/matchers/built_in/include_spec.rb | 139 +++++++++++++++++++ 4 files changed, 231 insertions(+), 29 deletions(-) diff --git a/Changelog.md b/Changelog.md index 5def085aa..0baaa78cc 100644 --- a/Changelog.md +++ b/Changelog.md @@ -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. diff --git a/features/built_in_matchers/include.feature b/features/built_in_matchers/include.feature index 0f9357f85..d686231b5 100644 --- a/features/built_in_matchers/include.feature +++ b/features/built_in_matchers/include.feature @@ -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") @@ -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) + expect([1, 2]).to include(be < 10).at_least(2).times expect([1, 2]).not_to include(17) ``` @@ -41,7 +42,7 @@ 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) } @@ -49,6 +50,7 @@ Feature: `include` matcher # 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) } @@ -61,14 +63,16 @@ 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: @@ -76,23 +80,26 @@ Feature: `include` matcher 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: diff --git a/lib/rspec/matchers/built_in/include.rb b/lib/rspec/matchers/built_in/include.rb index 0a654268a..5a0d697f0 100644 --- a/lib/rspec/matchers/built_in/include.rb +++ b/lib/rspec/matchers/built_in/include.rb @@ -1,13 +1,17 @@ +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 + # @api private def initialize(*expecteds) @expecteds = expecteds end @@ -15,21 +19,29 @@ def initialize(*expecteds) # @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 @@ -62,12 +74,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) @@ -79,10 +112,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 @@ -134,6 +166,28 @@ def actual_collection_includes?(expected_item) actual.any? { |value| values_match?(expected_item, value) } end + if RUBY_VERSION < '1.9' + def count_enumerable(expected_item) + actual.select { |value| values_match?(expected_item, value) }.size + end + else + def count_enumerable(expected_item) + actual.count { |value| values_match?(expected_item, value) } + end + end + + def count_inclusions + @divergent_items = expected + case actual + when String + actual.scan(expected.first).length + when Enumerable + count_enumerable(expected.first) + else + raise NotImplementedError, '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) diff --git a/spec/rspec/matchers/built_in/include_spec.rb b/spec/rspec/matchers/built_in/include_spec.rb index b32bada7d..09c425bff 100644 --- a/spec/rspec/matchers/built_in/include_spec.rb +++ b/spec/rspec/matchers/built_in/include_spec.rb @@ -103,6 +103,12 @@ def hash.send; :sent; end expect(build_target(:foo => 1, :bar => 2)).to include(:foo => 1, :bar => 3) }.to fail_including(failure_string) end + + it 'does not support count constraint' do + expect { + expect(build_target(:key => 'value')).to include(:other).once + }.to raise_error(NotImplementedError) + end end describe "expect(...).to include(with_one_arg)" do @@ -115,6 +121,9 @@ def hash.send; :sent; end expect { expect(5).to include(1) }.to fail_matching("expected 5 to include 1, but it does not respond to `include?`") + expect { + expect(5).to include(1).once + }.to fail_matching("expected 5 to include 1 once, but it does not respond to `include?`") end end @@ -167,6 +176,46 @@ def hash.send; :sent; end expect(" foo\nbar\nbazz").to include("foo", "bar", "gaz") }.to fail_with(a_string_not_matching(/Diff/i)) end + + context "with exact count" do + it 'fails if the block yields wrong number of times' do + expect { + expect('foo bar foo').to include('foo').once + }.to fail_with(/expected "foo bar foo" to include "foo" once but it is included twice/) + end + + it 'passes if the block yields the specified number of times' do + expect('fooo bar').to include('oo').once + expect('fooo bar').to include('o').thrice + expect('fooo ooo oo bar foo').to include('oo').exactly(4).times + end + end + + context "with at_least count" do + it 'passes if the search term is included at least the number of times' do + expect('foo bar foo').to include('foo').at_least(2).times + expect('foo bar foo foo').to include('foo').at_least(:twice) + end + + it 'fails if the search term is included too few times' do + expect { + expect('foo bar foo').to include('foo').at_least(:thrice) + }.to fail_with(/expected "foo bar foo" to include "foo" at least 3 times but it is included twice/) + end + end + + context "with at_most count" do + it 'passes if the search term is included at most the number of times' do + expect('foo bar foo').to include('foo').at_most(2).times + expect('foo bar').to include('foo').at_most(:twice) + end + + it 'fails if the search term is included too many times' do + expect { + expect('foo bar foo foo').to include('foo').at_most(:twice) + }.to fail_with(/expected "foo bar foo foo" to include "foo" at most twice but it is included 3 times/) + end + end end context "for an array target" do @@ -193,6 +242,48 @@ def hash.send; :sent; end dbl = double.as_null_object expect([dbl]).to include(dbl) end + + context "with exact count" do + it 'fails if the block yields wrong number of times' do + expect { + expect([1, 2, 1]).to include(1).once + }.to fail_with('expected [1, 2, 1] to include 1 once but it is included twice') + expect { + expect([10, 20, 30]).to include(a_value_within(2).of(17)).twice + }.to fail_with('expected [10, 20, 30] to include (a value within 2 of 17) twice but it is included 0 times') + end + + it 'passes if the block yields the specified number of times' do + expect([1, 2, 1]).to include(1).twice + expect([10, 20, 30]).to include(a_value_within(5).of(17)).once + end + end + + context "with at_least count" do + it 'passes if the search term is included at least the number of times' do + expect([1, 2, 1]).to include(1).at_least(2).times + expect([1, 2, 1, 1]).to include(1).at_least(:twice) + end + + it 'fails if the search term is included too few times' do + expect { + expect([1, 2, 1]).to include(1).at_least(:thrice) + }.to fail_with('expected [1, 2, 1] to include 1 at least 3 times but it is included twice') + end + end + + context "with at_most count" do + it 'passes if the search term is included at most the number of times' do + expect([1, 2, 1]).to include(1).at_most(2).times + expect([1, 2]).to include(1).at_most(:twice) + end + + it 'fails if the search term is included too many times' do + expect { + expect([1, 2, 1, 1]).to include(1).at_most(:twice) + }.to fail_with('expected [1, 2, 1, 1] to include 1 at most twice but it is included 3 times') + end + end end context "for a hash target" do @@ -330,6 +421,18 @@ class PseudoHash < SimpleDelegator }.to fail_with(%r|expected #{hash_inspect :key => "value", :this => "that"} to include :nada, :nope, and :negative|) end end + + it 'does not implement count constraints' do + expect { + expect('').to include('foo', 'bar').once + }.to raise_error(NotImplementedError) + expect { + expect('').to include('foo', 'bar').at_least(:twice) + }.to raise_error(NotImplementedError) + expect { + expect('').to include('foo', 'bar').at_most(:twice) + }.to raise_error(NotImplementedError) + end end describe "expect(...).not_to include(expected)" do @@ -364,6 +467,42 @@ class PseudoHash < SimpleDelegator expect("abc").not_to include("c") }.to fail_with("expected \"abc\" not to include \"c\"") end + + context "with exact count" do + it 'passes if the block yields wrong number of times' do + expect('foo bar foo').not_to include('foo').once + end + + it 'fails if the block yields the specified number of times' do + expect { + expect('fooo bar').not_to include('oo').once + }.to fail_with(/expected "fooo bar" not to include "oo" once but it is included once/) + end + end + + context "with at_least count" do + it 'fails if the search term is included at least the number of times' do + expect { + expect('foo bar foo foo').not_to include('foo').at_least(:twice) + }.to fail_with(/expected "foo bar foo foo" not to include "foo" at least twice but it is included 3 times/) + end + + it 'passes if the search term is included too few times' do + expect('foo bar foo').not_to include('foo').at_least(:thrice) + end + end + + context "with at_most count" do + it 'fails if the search term is included at most the number of times' do + expect { + expect('foo bar').not_to include('foo').at_most(:twice) + }.to fail_with(/expected "foo bar" not to include "foo" at most twice but it is included once/) + end + + it 'passes if the search term is included too many times' do + expect('foo bar foo foo').not_to include('foo').at_most(:twice) + end + end end context "for an array target" do