Mike Vincent

use it or lose it.

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
So there we go.. custom matchers for the cleaner, clearer specs.
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.

2 Responses to “Extending Rspec”

  1. Chris Pratt Says:

    Hmmm… I think that guy was me. I was in #rspec, somewhere around two weeks ago, asking about spec’ing for validates_uniqueness_of.

    It’s encouraging to hear someone else say that this is the way they do it. Something about having to save first still just doesn’t seem right for a proper spec. But I’ve looked around since that night in #rspec and still haven’t been able to find another way. Ah well.

    Nice custom matchers, btw. I think I’ll add them to my project. Thanks.

  2. Mike Says:

    Since validates_uniqueness_of is ultimately doing a find(:first) with some conditions to see if a matching record already exists, you could stub the find and not need to do any saving. Something like this untested replacement of the matches? method:

                def matches?(obj)
                    klass = obj.class
                    klass.stub!(:find).and_return(true)
                    obj.valid?
                    !obj.errors[@attribute].nil?
                end
    

    I think I still prefer the original way though. Not sure how to articulate case insensitivity off the top of my head using stubs, either. :)

Sorry, comments are closed for this article.

Mike Vincent uses the Shay theme, and is Powered by Mephisto