Extending Rspec
August 30th, 2007
UPDATE2: Don't Do this.. Don't try this.. Just move along, nothing to see here. I'll make a follow up post explaining why when I get a chance.
UPDATE: update: r2536 in rspec trunk renamed BehaviorApi to ExampleApi, I updated the code below to reflect that.
Last night a fella in #rspec on freenode was inquiring how to specify an ActiveRecord attribute should be unique. The advice given was to save the object being specified and attempt to create another object, duplicating the attribute in question's value, then specify the new object should not be valid and should contain an error against said attribute.
This is how I do it as well. But, I've often itched to just encapsulate it into a custom matcher so I wasn't repeatidly cluttering my specs with that setup each time I wanted to specify a unique attribute.
So I made a spec/my_matchers.rb file and required it from my spec/spec_helper.rb as well as adding a punch to the behavior api so that it gets included.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
# my_matchers.rb module My module SpecMatchers class UnsavedRecord < StandardError; end class HaveAUnique #:nodoc: def initialize(attribute, opts = {}) @attribute = attribute @options = {:case_sensitive => true}.merge(opts) end def matches?(obj) raise UnsavedRecord, "Can only check uniqueness against saved records, save your object first." if obj.new_record? @value = obj.send(@attribute) klass = obj.class new_obj = klass.new(@attribute => @value) new_obj.valid? ok = !new_obj.errors[@attribute].nil? # check if it should be unique even with different casing if ok && !@options[:case_sensitive] new_obj = klass.new(@attribute => flip_case(@value)) new_obj.valid? ok = !new_obj.errors[@attribute].nil? end ok end def flip_case(string) string.size.times do |i| string[i] = string[i].chr.send(:upcase) == string[i].chr ? string[i].chr.send(:downcase) : string[i].chr.send(:upcase) end string end def failure_message msg = "expected #{@attribute} to be unique, but was able to create another object having #{@attribute} => #{@value} without errors\n" end def negative_failure_message failure_message end def description "unique attribute value" end end def have_a_unique(*exp) HaveAUnique.new(*exp) end end end |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
# somewhere in my spec/spec_helper.rb require File.dirname(__FILE__) + '/my_matchers.rb' # Open up the behaviors api and stick our custom matchers in # module Spec::DSL::BehaviourAp # update: r2536 in rspec trunk renamed BehaviorApi to ExampleApi, so now it's... module Spec::DSL::ExampleApi def before_eval module_eval do include My::SpecMatchers end end end # I -think- this is how you'd do it using rspec versions < 1.0.8 # the project I did this within, I'm using trunk. # class Spec::DSL::Behaviour # def before_eval # @eval_module.include My::SpecMatchers # end # end |
That's pretty much it.. So now I can write a spec like..
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
describe Sompn do before do @sompn = Sompn.create!(:name => 'foo', :phone => '123-555-1212') end it "should have a unique case insensitive name" do @sompn.should have_a_unique(:name, :case_sensite => false) end it "should have a unique phone" do @sompn.should have_a_unique(:phone) end end |
Gives me further ideas to clear up my specs.. Maybe a have_a(attribute, {:required => false}} matcher that simply specifies the presence of an attribute and whether it is actually required to be present. That'd be just as nifty.. let's do it! Open up the my_matchers.rb file and add this after the have_a_unique definition.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 |
# Have a ....
class HaveA #:nodoc:
def initialize(attribute, opts = {})
@attribute = attribute
@options = {:required => false}.merge(opts)
end
def matches?(obj)
@klass = obj.class
@exist = obj.respond_to?(@attribute.to_sym)
if @options[:required]
obj.send("#{@attribute}=",nil)
obj.valid?
return !obj.errors[@attribute].nil?
end
@exist
end
def failure_message
if @options[:required] && @exist
"expected #{@attribute} to be required but having a nil value is valid"
else
"expected #{@klass} object to respond to #{@attribute}"
end
end
def negative_failure_message
failure_message
end
def description
"unique attribute value"
end
end
def have_a(*exp)
HaveA.new(*exp)
end
|
And now I specify attributes exist, possibly even required like..
1 2 3 4 5 6 7 8 9 10 |
describe Sompn do it "should have a foo, but not require it" do @sompn.should have_a(:foo) end it "should require a bar" do @sompn.should have_a(:bar, :required => true) end end |
UpdateOh, I know the site's layout and all is pretty crappy.. It'll be getting some loving soon, I hope, it's been on the todo list for a long time already.







