Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

Fuzzy include matchers #85

Closed
wants to merge 6 commits into from

5 participants

Luke Redpath David Chelimsky Myron Marston Chris Corbyn alindeman
Luke Redpath

I often want to assert the inclusion of objects in a collection in a more fuzzy way; this often makes tests less brittle as they aren't quite as coupled with the overall equality of objects inside the collection.

This pull request allows fuzzy matching by extending the include() matcher to take matchers as arguments.

A simple example might be:

# where a_user_named is a pre-defined matcher
collection_of_users.should include( a_user_named("Bob") )

I've only implemented this for arrays and the specs/features reflect that but it could possibly be extended to support hashes too.

All specs/features passing locally for me.

lukeredpath added some commits
Luke Redpath lukeredpath Written a failing feature to describe how fuzzy collection matching w…
…orks.
d0d5100
Luke Redpath lukeredpath Should be able to do fuzzy matching against arrays using include?(som…
…e_matcher) or include?(array_of_matchers).

I've deliberately only implemented fuzzy matching support for arrays right now, it might make sense
to extend support to hashes though.
7eed1e0
Luke Redpath lukeredpath Slight tweak to the cucumber feature to get it passing. 15e2cc3
David Chelimsky
Owner

@lukeredpath - nice to see you contributing again!

I'm a little uncomfortable with is_a?(Matcher). We could resolve that with a duck-type check, but another approach to this is just to alias_method :==, :matches? on Matcher. That's how the mock argument matchers work, and there's no reason not to do the same here. The only catch is that the failure message isn't good out of the box, so we'd want a way to improve it. Thoughts?

Luke Redpath

To be honest, so was I and my original thought was to actually just check with a responds_to?(:matches?) instead.

Aliasing :== to :matches? works for me if it doesn't break anything (I'll check). What about dealing with the matcher in to_word? I think I could live with that...what about you?

Luke Redpath

OK, I might be missing something obvious but aliasing :== and :matches? doesn't seem to do the trick, namely because she implementation of Array#include calls #== on the collection member, not the matcher, i.e. matcher == object is true but object == matcher is false.

Myron Marston

This looks fantastic! We've tossed around some better ways to do "fuzzy" collection matchers before and never really came up with an API we all liked and agreed with. I really like this.

I was just thinking that this probably won't work with the operator matchers (i.e. should include( > 7)) although it might if you use be--should include(be > 7) but I'm not sure about that. Either way, it'd probably be good to document any matchers that won't work with this in the cuke.

Luke Redpath

I notice you seem to use Cucumber for a lot of documentation; what would be the best way of documenting this?

It's probably worth writing a few specs to see what works and what doesn't either way.

Myron Marston

The cukes are indeed the source of the official docs these days, so that'd be great if you can add a note about the supported matchers to the cuke. We tend to use the cuke feature narrative to put free-form feature discussion. I think that'd be a great place for a note about this.

Luke Redpath lukeredpath Started to add some integration specs to show the interaction between…
… include()

matcher support and the built-in matchers.
556c3f6
Luke Redpath

I've started work on some integration specs for this feature. It's not exhaustive as I wanted to get some feedback on whether you are happy with the approach I've taken (Cucumber documentation will still be needed too).

I didn't feel happy putting these specs in either the include() matcher specs or individual matcher specs; they felt like integration specs so I named it as such.

If you think this is the right idea, I'll find the time to finish them all off. Where a built-in matcher doesn't work, then the integration spec can simply be written to express that.

Myron Marston

I think your sense is right to put them in another spec file.

I don't think we necessarily need a spec for every built in matcher. All of the methods that return matcher objects should work fine. My concern was for the non-standard matchers--rspec-matchers has support for them baked directly into the handler and I doubt they will work.

@dchelimsky: can you weigh in about what you'd like to see as far as docs/specs about what matchers are compatible with this feature?

On a side note: I'm noticing that the wording of a matcher that works well with this feature is awkward when used alone, and vice-versa. I'm not sure if there's anything that can be done about that, though. It's easy enough for people to alias the matchers they use with this to something that reads nicer.

Luke Redpath

I agree that the built-in matchers don't tend to read well when used with this, but I'm not sure that's a terrible thing. As you say, people can alias, but in all honesty, I think people should be encouraged to write their own domain-specific matchers for this kind of thing. Perhaps this should be gently hinted at in documentation. Every time I've felt the need for this feature I've used a domain-specific matcher.

Having said that, I'm not sure it's as bad the other way around, e.g.:

# this would of course be a rubbish spec
user = User.new(:name => "joe")
user.should == a_user_named("joe")

I'm not sure there is any harm in adding a simple spec for each built-in matcher unless you think it adds some maintenance overhead. It's easy to say it should work fine, but it's better to say it does work fine. ;)

David Chelimsky
Owner

Assuming this will work with all matchers aside from the operator matchers, I think just a couple of examples of it working correctly and text docs saying "operator matchers are not supported" is fine. Agree it should be in the cuke - not sure I agree it needs a separate integration spec.

Chris Corbyn

I often find myself attempting this:

@some_collection.should include { |v| ... some arbitrary logic }

Which effectively would be the RSpec way of describing any?.

David Chelimsky
Owner

@d11wtq just use include:

@some_collection.should include("some value")

That's the same as saying:

@some_collection.any? {|e| e == "some value"}.should be_true

If that doesn't answer your question, however, please write the rspec users list rather than diverting this thread.

alindeman
Collaborator

@myronmarston, @dchelimsky, any thoughts on this? Are we interested in rebasing this and pulling it in? If so, is there anything about the current implementation that needs to be adjusted?

Myron Marston
Owner

I still like this a lot. (Sorry about dropping the ball about following up; it's hard to stay on top of all the issues and pull requests!).

A couple concerns I have:

  • Does this work well with something like expect(collection).not_to include(a_user_named("Jack"), a user_named("Jill"))? We had issues a while back where matchers that operate on collections and accepted multiple things to match against (like this does) would work improperly. Consider a case like [1, 2, 3].should_not include(3, 4). The original logic allowed this to pass, because include_matcher.matches? returned false (that is, [1, 2, 3] does not include both 3 and 4). However, we decided that the expected semantics of should_not include(a, b) are that it only passes if BOTH a and b are not included. That's where the send(predicate) business in the matcher comes from.
  • The "voice" of a matcher defined for this is quite different from normal matchers...you wouldn't say user.should a_user_named("Jack"). Not sure what if anything we can do about this.
Myron Marston myronmarston was assigned
Myron Marston myronmarston referenced this pull request from a commit
Myron Marston myronmarston Add changelog entry for #85. 07a064a
Myron Marston

Sorry it's taken me so long...but I finally merged this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Commits on Jul 25, 2011
  1. Luke Redpath
  2. Luke Redpath

    Should be able to do fuzzy matching against arrays using include?(som…

    lukeredpath authored
    …e_matcher) or include?(array_of_matchers).
    
    I've deliberately only implemented fuzzy matching support for arrays right now, it might make sense
    to extend support to hashes though.
  3. Luke Redpath
  4. Luke Redpath
Commits on Jul 26, 2011
  1. Luke Redpath

    Started to add some integration specs to show the interaction between…

    lukeredpath authored
    … include()
    
    matcher support and the built-in matchers.
  2. Luke Redpath
This page is out of date. Refresh to see the latest.
49 features/built_in_matchers/include.feature
View
@@ -119,3 +119,52 @@ Feature: include matcher
"""
When I run `rspec hash_include_matcher_spec.rb`
Then the output should contain "13 failure"
+
+ Scenario: fuzzy usage with matchers
+ Given a file named "fuzzy_include_matcher_spec.rb" with:
+ """
+ require 'ostruct'
+
+ class User < OpenStruct
+ def inspect
+ name
+ end
+ end
+
+ RSpec::Matchers.define :a_user_named do |expected|
+ match do |actual|
+ actual.is_a?(User) && (actual.name == expected)
+ end
+ description do
+ "a user named '#{expected}'"
+ end
+ end
+
+ describe "Collection of users" do
+ subject do
+ [User.new(:name => "Joe"),
+ User.new(:name => "Fred"),
+ User.new(:name => "John"),
+ User.new(:name => "Luke"),
+ User.new(:name => "David")]
+ end
+
+ it { should include( a_user_named "Joe" ) }
+ it { should include( a_user_named "Luke" ) }
+ it { should_not include( a_user_named "Richard" ) }
+ it { should_not include( a_user_named "Hayley" ) }
+
+ # deliberate failures
+ it { should include( a_user_named "Richard" ) }
+ it { should_not include( a_user_named "Fred" ) }
+ it { should include( a_user_named "Sarah" ) }
+ it { should_not include( a_user_named "Luke" ) }
+ end
+ """
+ When I run `rspec fuzzy_include_matcher_spec.rb`
+ Then the output should contain all of these:
+ | 8 examples, 4 failures |
+ | expected [Joe, Fred, John, Luke, David] to include a user named 'Richard' |
+ | expected [Joe, Fred, John, Luke, David] not to include a user named 'Fred' |
+ | expected [Joe, Fred, John, Luke, David] to include a user named 'Sarah' |
+ | expected [Joe, Fred, John, Luke, David] not to include a user named 'Luke' |
8 lib/rspec/matchers/include.rb
View
@@ -28,13 +28,15 @@ def include(*expected)
match_for_should_not do |actual|
perform_match(:none?, :any?, actual, _expected)
end
-
+
def perform_match(predicate, hash_predicate, actual, _expected)
_expected.send(predicate) do |expected|
if comparing_hash_values?(actual, expected)
expected.send(hash_predicate) {|k,v| actual[k] == v}
elsif comparing_hash_keys?(actual, expected)
actual.has_key?(expected)
+ elsif comparing_with_matcher?(actual, expected)
+ actual.any? { |value| expected.matches?(value) }
else
actual.include?(expected)
end
@@ -48,6 +50,10 @@ def comparing_hash_keys?(actual, expected) # :nodoc:
def comparing_hash_values?(actual, expected) # :nodoc:
actual.is_a?(Hash) && expected.is_a?(Hash)
end
+
+ def comparing_with_matcher?(actual, expected)
+ actual.is_a?(Array) && expected.respond_to?(:matches?)
+ end
end
end
end
6 lib/rspec/matchers/pretty.rb
View
@@ -6,7 +6,7 @@ def split_words(sym)
end
def to_sentence(words)
- words = words.map{|w| w.inspect}
+ words = words.map{|w| to_word(w) }
case words.length
when 0
""
@@ -32,6 +32,10 @@ def _pretty_print(array)
end
result
end
+
+ def to_word(item)
+ item.respond_to?(:description) ? item.description : item.inspect
+ end
end
end
end
30 spec/rspec/matchers/include_matcher_integration_spec.rb
View
@@ -0,0 +1,30 @@
+require 'spec_helper'
+
+module RSpec
+ module Matchers
+ describe "include() interaction with built-in matchers" do
+ it "works with be_within(delta).of(expected)" do
+ [10, 20, 30].should include( be_within(5).of(24) )
+ [10, 20, 30].should_not include( be_within(3).of(24) )
+ end
+
+ it "works with be_instance_of(klass)" do
+ ["foo", 123, {:foo => "bar"}].should include( be_instance_of(Hash) )
+ ["foo", 123, {:foo => "bar"}].should_not include( be_instance_of(Range) )
+ end
+
+ it "works with be_kind_of(klass)" do
+ class StringSubclass < String; end
+ class NotHashSubclass; end
+
+ [StringSubclass.new("baz")].should include( be_kind_of(String) )
+ [NotHashSubclass.new].should_not include( be_kind_of(Hash) )
+ end
+
+ it "works with be_[some predicate]" do
+ [stub("actual", :happy? => true)].should include( be_happy )
+ [stub("actual", :happy? => false)].should_not include( be_happy )
+ end
+ end
+ end
+end
37 spec/rspec/matchers/include_spec.rb
View
@@ -339,3 +339,40 @@
end
end
end
+
+RSpec::Matchers.define :string_containing_string do |expected|
+ match do |actual|
+ actual.include?(expected)
+ end
+ description do
+ "a string containing '#{expected}'"
+ end
+end
+
+describe "should include(matcher)" do
+ context 'for an array target' do
+ it "passes if target includes an object that satisfies the matcher" do
+ ['foo', 'bar', 'baz'].should include(string_containing_string("ar"))
+ end
+
+ it "fails if target doesn't include object that satisfies the matcher" do
+ lambda {
+ ['foo', 'bar', 'baz'].should include(string_containing_string("abc"))
+ }.should fail_matching(%Q|expected #{['foo', 'bar', 'baz'].inspect} to include a string containing 'abc'|)
+ end
+ end
+end
+
+describe "should include(multiple, matcher, arguments)" do
+ context 'for an array target' do
+ it "passes if target includes items satisfying all matchers" do
+ ['foo', 'bar', 'baz'].should include(string_containing_string("ar"), string_containing_string('oo'))
+ end
+
+ it "fails if target does not include an item satisfying any one of the items" do
+ lambda {
+ ['foo', 'bar', 'baz'].should include(string_containing_string("ar"), string_containing_string("abc"))
+ }.should fail_matching(%Q|expected #{['foo', 'bar', 'baz'].inspect} to include a string containing 'ar' and a string containing 'abc'|)
+ end
+ end
+end
Something went wrong with that request. Please try again.