Kerry Buckley

What’s the simplest thing that could possibly go wrong?

DRYing out model specs

5 comments

[Updated 14/3/07: corrected specify_attributes as per Paul's comment]
[Updated 18/12/07: modified to avoid crazy RSpec errors]

A week or so ago I wrote about writing specs for simple pieces of functionality (particularly those that are arguably just configuration, like Rails validations). I argued that it's important to test-drive even the simple things – however, the amount of test code can get out of hand.

Here's a spec for the single validation from the previous post, rewritten using one expectation per example:

RUBY:
  1. def valid_attrs
  2.   {
  3.     :username => 'fred'
  4.   }
  5. end
  6.  
  7. describe "A user without a username" do
  8.   setup do
  9.     @user = User.new
  10.     @user.attributes = valid_user_attributes.except(:username)
  11.   end
  12.  
  13.   it "should be invalid" do
  14.     @user.should_not_be_valid
  15.   end
  16.  
  17.   it "should have an error on the username attribute" do
  18.     @user.errors.on(:username).size.should == 1
  19.   end
  20. end

And here's the massive amount of code that satisfies that spec:

RUBY:
  1. class User <ActiveRecord::Base
  2.   validates_presence_of :username
  3. end

Clearly once you have a few more attributes you end up with a lot of test code, much of which is repetitive.

I decided to try to create a helper method (see below) to reduce the clutter. Here's how to use it to specify that:

  • The username, first_name and last_name attributes are mandatory
  • The age attribute must be numeric
  • The email attribute must be at least three characters long
  • The username, first_name, last_name and email attributes must be no more than 12, 20, 20 and 200 characters long respectively
RUBY:
  1. def valid_attrs
  2.   {
  3.     :username => 'fred',
  4.     :first_name => 'Fred',
  5.     :last_name => 'Bloggs',
  6.     :email => 'fred@bloggs.com',
  7.     :age => 21
  8.   }
  9. end
  10.  
  11. specify_attributes User, valid_attrs,
  12. {
  13.   :mandatory => [:username, :first_name, :last_name],
  14.   :numeric => [:age],
  15.   :min_lengths => {:email => 3},
  16.   :max_lengths => {:username => 12, :first_name => 20, :last_name => 20, :email => 200}
  17. }

This automatically executes the following specs:

A User with all attributes set
- should be valid

A User with no username
- should be invalid
- should not allow saving
- should cause exactly one error when save attempted
- should set an error on the username field

A User with no first_name
- should be invalid
- should not allow saving
- should cause exactly one error when save attempted
- should set an error on the first_name field

A User with no last_name
- should be invalid
- should not allow saving
- should cause exactly one error when save attempted
- should set an error on the last_name field

A User with non-numeric age
- should be invalid
- should not allow saving
- should cause exactly one error when save attempted
- should set an error on the age field

A User with email of length 3
- should be valid

A User with email of length 2
- should be invalid
- should not allow saving
- should cause exactly one error when save attempted
- should set an error on the email field

A User with last_name of length 20
- should be valid

A User with last_name of length 21
- should be invalid
- should not allow saving
- should cause exactly one error when save attempted
- should set an error on the last_name field

A User with username of length 12
- should be valid

A User with username of length 13
- should be invalid
- should not allow saving
- should cause exactly one error when save attempted
- should set an error on the username field

A User with first_name of length 20
- should be valid

A User with first_name of length 21
- should be invalid
- should not allow saving
- should cause exactly one error when save attempted
- should set an error on the first_name field

A User with email of length 200
- should be valid

A User with email of length 201
- should be invalid
- should not allow saving
- should cause exactly one error when save attempted
- should set an error on the email field

Here's the code that makes it possible (I added it to spec_helper.rb).

First the ubiquitous except Hash extension:

RUBY:
  1. class Hash
  2.   # Usage { :a => 1, :b => 2, :c => 3}.except(:a) -> { :b => 2, :c => 3}
  3.   def except(*keys)
  4.     self.reject { |k,v|
  5.       keys.include? k.to_sym
  6.     }
  7.   end
  8. end

A couple of local helper methods (I factored out should_cause_error_on from the repetitive specs for validation failure):

RUBY:
  1. def string_of_length(length)
  2.   (1..length).collect {'a'}.join
  3. end
  4.  
  5. def should_cause_error_on(attr)
  6.   it "should be invalid" do
  7.     @obj.should_not be_valid
  8.   end
  9.  
  10.   it "should not allow saving" do
  11.     @obj.save.should be_false
  12.   end
  13.  
  14.   it "should cause exactly one error when save attempted" do
  15.     @obj.save
  16.     @obj.errors.size.should == 1
  17.   end
  18.  
  19.   it "should set an error on the #{attr} field" do
  20.     @obj.errors_on(attr).size.should == 1
  21.   end
  22. end

And finally the actual specify_attributes method:

RUBY:
  1. def specify_attributes(clazz, attr_list, options = {})
  2.   label = "#{clazz =~ /^aeiouy/i ? 'An' : "A"} #{clazz}"
  3.   describe "#{label} with all attributes set", :type => 'model' do
  4.     setup do
  5.       @obj = clazz.new
  6.       @obj.attributes = attr_list
  7.     end
  8.  
  9.     it "should be valid" do
  10.       @obj.should be_valid
  11.     end
  12.   end
  13.  
  14.   if options[:mandatory]
  15.     options[:mandatory].each do |attr|
  16.  
  17.       describe "#{label} with no #{attr}", :type => 'model' do
  18.         setup do
  19.           @obj = clazz.new
  20.           @obj.attributes = valid_attrs.except attr
  21.         end
  22.        
  23.         should_cause_error_on attr
  24.       end
  25.     end
  26.   end
  27.  
  28.   if options[:numeric]
  29.     options[:numeric].each do |attr|
  30.       describe "#{label} with non-numeric #{attr}", :type => 'model' do
  31.         setup do
  32.           @obj = clazz.new
  33.           @obj.attributes = valid_attrs.except attr
  34.           eval("@obj.#{attr} = string_of_length valid_attrs[attr].to_s.size")
  35.         end
  36.  
  37.         should_cause_error_on attr
  38.       end
  39.     end
  40.   end
  41.  
  42.   if options[:min_lengths]
  43.     options[:min_lengths].keys.each do |attr|
  44.       limit = options[:min_lengths][attr]
  45.  
  46.       describe "#{label} with #{attr} of length #{limit}", :type => 'model' do
  47.         setup do
  48.           @obj = clazz.new
  49.           @obj.attributes = valid_attrs.except attr
  50.           eval("@obj.#{attr} = string_of_length limit")
  51.         end
  52.  
  53.         it "should be valid" do
  54.           @obj.should be_valid
  55.         end
  56.       end
  57.  
  58.       describe "#{label} with #{attr} of length #{limit - 1}", :type => 'model' do
  59.         setup do
  60.           @obj = clazz.new
  61.           @obj.attributes = valid_attrs.except attr
  62.           eval("@obj.#{attr} = string_of_length(limit - 1)")
  63.         end
  64.  
  65.         should_cause_error_on attr
  66.       end
  67.     end
  68.   end
  69.  
  70.   if options[:max_lengths]
  71.     options[:max_lengths].keys.each do |attr|
  72.       limit = options[:max_lengths][attr]
  73.  
  74.       describe "#{label} with #{attr} of length #{limit}", :type => 'model' do
  75.         setup do
  76.           @obj = clazz.new
  77.           @obj.attributes = valid_attrs.except attr
  78.           eval("@obj.#{attr} = string_of_length limit")
  79.         end
  80.  
  81.         it "should be valid" do
  82.           @obj.should be_valid
  83.         end
  84.       end
  85.  
  86.       describe "#{label} with #{attr} of length #{limit + 1}", :type => 'model' do
  87.         setup do
  88.           @obj = clazz.new
  89.           @obj.attributes = valid_attrs.except attr
  90.           eval("@obj.#{attr} = string_of_length(limit + 1)")
  91.         end
  92.  
  93.         should_cause_error_on attr
  94.       end
  95.     end
  96.   end
  97. end

It ought to be possible to extend this to other validations too, as well as the various options like :message, :on, :allow_nil and so on, but that's for another day.

Technorati Tags: , , , , ,

Written by Kerry

March 11th, 2007 at 9:10 pm

Posted in Agile,Rails,Ruby

5 Responses to 'DRYing out model specs'

Subscribe to comments with RSS or TrackBack to 'DRYing out model specs'.

  1. You’ve got an incorrectly named variable in the specify_attributes method: you pass the valid attributes in as attr_list but within the method you refer to valid_attrs.

    (The example as it stands it works as externally you’ve got a method defined called valid_attr which contains the valid attributes)

    Paul Moser

    14 Mar 07 at 6:44 pm

  2. Oops. Now fixed.

    Kerry

    14 Mar 07 at 7:11 pm

  3. You’ve actually touched upon a controversial issue. Should my specs be DRY or should they be as expressive as possible? The general rule of thumb among the testing community seems to be in favor of expressive as possible.

    First off, I appreciate the amount of work you put into this and it seems to be a very powerful helper method. So congrats on that.

    However, there are a few things that do worry me about this code. First, the actual ‘spec’ you end up writing is hardly more expressive than the actual implementation code: not that it necessarily has to be, but it does sort of defeat the purpose of spec’ing in the first place.

    Second, you’ve initiated a point of failure in the spec, where you spec may fail, not because the implementation of your applications code is wrong, but because there’s a bug in the helper method.

    Third, you now have a rather large amount of untested code, unless you proceed to write test code for the code that generates the code for your test… head spinning…

    All that aside, this is a very ingenious little helper method, so good job on that front.

    Chris Pratt

    18 Sep 07 at 8:22 pm

  4. Chris, some very good points, most of which I was aware of and all of which I fully agree with.

    At the time, I decided that the trade-off between the downsides and having to write potentially hundreds of lines of test code for a single line in the model was worth it. I now think there’s a better way to do this, and I’m going to try re-implementing the helper as a generator, so the test code actually exists as a skeleton and can be changed afterwards.

    Watch this space.

    Kerry

    19 Sep 07 at 10:02 am

  5. [...] seemed that the error was being triggered by the rather unpleasant code I wrote a while ago to simplify testing of model validation. Digging into the RSpec source to see what was happening, I found that that error message only gets [...]

Leave a Reply