diff --git a/Changelog.md b/Changelog.md index 23d5f738c..9b5df53c5 100644 --- a/Changelog.md +++ b/Changelog.md @@ -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. diff --git a/features/built_in_matchers/include.feature b/features/built_in_matchers/include.feature index 0f9357f85..dfdc76d66 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") @@ -12,7 +12,7 @@ Feature: `include` matcher 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) ``` @@ -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) } @@ -61,14 +61,15 @@ 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: @@ -76,23 +77,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..87dc51350 100644 --- a/lib/rspec/matchers/built_in/include.rb +++ b/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 @@ -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) @@ -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 @@ -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) diff --git a/spec/rspec/matchers/built_in/include_spec.rb b/spec/rspec/matchers/built_in/include_spec.rb index 96d94e788..31ce87f17 100644 --- a/spec/rspec/matchers/built_in/include_spec.rb +++ b/spec/rspec/matchers/built_in/include_spec.rb @@ -100,6 +100,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 @@ -112,6 +118,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 @@ -164,6 +173,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 @@ -190,6 +239,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 @@ -327,6 +418,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 @@ -361,6 +464,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