Skip to content

Instantly share code, notes, and snippets.

@jimweirich
Created October 12, 2010 05:02
Show Gist options
  • Save jimweirich/621686 to your computer and use it in GitHub Desktop.
Save jimweirich/621686 to your computer and use it in GitHub Desktop.
# Allow RSpec assertions over the elements of a collection. For example:
#
# collection.should all_be > 0
#
# will specify that each element of the collection should be greater
# than zero. Each element of the collection that fails the test will
# be reported in the error message.
#
# Examples:
# [1,1,1].should all_be eq(1)
# [2,4,6].should all_be_even
# [3,6,9].should all_be_divisible_by(3)
# [1,1,1].should all_be == 1
# [2,3,5].should all_be { |n| prime?(n) }
#
# (for appropriate definitions of prime? and divisible_by?)
#
module RSpec
module Matchers
# Helper functions gathered here to avoid global name space
# pollution.
module AllBe
module_function
# Format a predicate function (with option arguments) for human
# readability.
def format_predicate(pred, *args)
message_elements =
["be #{pred.gsub(/_/, ' ')}"] +
args.map { |a| a.inspect }
message_elements.join(' ')
end
# Create a collection matcher using +block+ as the matching
# condition. Block should take one or two arguments:
#
# lambda { |element| ... }
# lambda { |element, messages| ... }
#
# If a block wishes to use custom failure messages, it should
# append the message to the +messages+ array. Otherwise we will
# format an appropriate error message for each failing element.
#
def make_matcher(condition_string, &block)
Matcher.new :all_be, block do |_block_|
@failing_messages = []
@broken = []
match do |actual|
actual.each do |element|
unless _block_.call(element, @failing_messages)
@broken << element
end
end
@broken.empty?
end
failure_message_for_should do |actual|
messages = ["in #{actual.inspect}:"]
if @failing_messages.empty?
messages += @broken.map { |element| "expected #{element.inspect} to #{condition_string}" }
else
messages += @failing_messages
end
messages.join("\n")
end
failure_message_for_should_not do |actual|
"expected #{actual.inspect} to not all #{condition_string}"
end
description do
"all be"
end
end
end
end
# Handle mapping operators to appropriate collection mappers.
class AllBeOperatorMatcher
[:==, :!=, :>, :<, :<=, :>=].each do |op|
define_method(op) do |value|
AllBe.make_matcher("be #{op} #{value}") { |element|
element.send(op, value)
}
end
end
end
alias method_missing_without_all_be method_missing
# Handle all_be_XXX predicates. If it doesn't match the
# all_be_XXX pattern, delegate to the existing method missing
# handler.
def method_missing(sym, *args, &block)
if sym.to_s =~ /^all_be_(\w+)$/
pred = $1
pred_method = "#{pred}?"
pred_string = AllBe.format_predicate(pred, *args)
AllBe.make_matcher(pred_string) { |element|
element.send(pred_method, *args, &block)
}
else
method_missing_without_all_be(sym, *args, &block)
end
end
# Return a matcher appropriate for matching across elements of a
# collection.
def all_be(matcher=nil, &block)
if matcher
AllBe.make_matcher("pass") { |element, messages|
if matcher.matches?(element)
true
else
messages << matcher.failure_message_for_should
false
end
}
elsif block_given?
AllBe.make_matcher("satisfy the block", &block)
else
AllBeOperatorMatcher.new
end
end
end
end
@OFdtripp
Copy link

Hiya Jim... Maybe I'm just being anal, but shouldn't "method_missing_without_be_all" be "method_missing_without_all_be"? It seems that you're using _all_be everywhere else...

@OFdtripp
Copy link

Line 116 too...

@jimweirich
Copy link
Author

"Foolish consistency is the hobgoblin of little minds" -- Emerson

But you're right. Thanks.

@OFdtripp
Copy link

Consistency != Foolishness. The reason for the consistency I suggested is that it makes your code more comprehensible. Like written prose, consistent and well-written code is reflective of consistent and well-reasoned thinking.

I don't think putting someone who is trying to help improve your code in a defensive position is behavior that will encourage future commenting or a future desire to help.

@jimweirich
Copy link
Author

Ok, the version here is cleaned up and now handing the edgecases.

@jimweirich
Copy link
Author

OFdtripp: My apologies. I meant that as a playful commentary (after all, I did admit you were correct). I apologize for not successfully communicating that your comment was indeed welcome.

@jarmo
Copy link

jarmo commented Oct 13, 2010

There is only one question i got when reading this: why to create it at all? Any reason for not using "all?" with RSpec? Did you just want to try it out how complex or easy would it be to create such a matcher or what was the initial purpose?

I would have saved 127 lines of code and used vanilla RSpec like this:
[1,1,1].should be_all {|e| e == 1}
[2,4,6].should be_all {|e| e % 3 == 0}

I guess you know how this continues.

@jimweirich
Copy link
Author

Several reasons:

  1. To explore RSpec matchers in depth
  2. To demonstrate how much work this faux-english approach actually takes (as you point out, 127 lines of rather dense code)
  3. To get better error messages (see example below)

Better error messages:
Given:

describe Array do
  subject { [2,3,4,5] }
  it { should be_all { |n| n.even? } }
  it { should all_be_even }
end

The be_all version reports:

  1) Array 
     Failure/Error: it { should be_all { |n| n.even? } }
     expected all? to return true, got false
     # ./xx_spec.rb:5:in `block (2 levels) in <top (required)>'

The all_be version reports:

  2) Array 
     Failure/Error: it { should all_be_even }
     in [2, 3, 4, 5]:
     expected 3 to be even
     expected 5 to be even
     # ./xx_spec.rb:6:in `block (2 levels) in <top (required)>'

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment