Homebrewed ActiveRecord test object factories for rspec
I recently took GWW from a handful of Test::Unit tests to 100% test coverage. It took some good tools (rspec and RR (Double Ruby) are both terrific), but what I didn’t do was use either of the widely used test object factory libraries, factory_girl or Machinist. I wasn’t going to use Rails fixtures, either — fixtures are just wrong — but the factory libraries rubbed me the wrong way. I could point to bits of each tool that weren’t exactly what I wanted, but the real reason I didn’t use either one was that they both seemed a little much when all I wanted to do was construct a few objects. Yes, I recognized the sound of a wheel about to be reinvented, but I went ahead anyway, and I ended up with a small amount of code very well tuned to my needs, and a couple of nice features that I don’t think the off-the-shelf solutions provide.
First, the requirements:
- Test objects in model specs should always be saved in the database, since dealing with the database is most of what they do. In other specs (helper, controller, view, routing) objects should never be saved, since the model methods that would need objects to be in the database are always stubbed or mocked and saving is slow. Object factories should create objects appropriate to the type of spec they’re called from.
- All attributes should have defaults that are easily readable in printed output and in a debugger, and when possible say what object’s attribute and what attribute they’re a value of.
- It should be easy to make multiple instances of a class without violating uniqueness constraints.
- It should be easy to specify the value of any attribute at creation time.
- If an object isn’t saved in the database, it should be possible to specify its ID in the statement that creates it, just like any other attribute. (URLs in view tests look a lot better with non-nil IDs.) ActiveRecord normally prevents one from constructing an object with an ID, so a little extra help is needed here.
ActiveRecord already provides succinct ways to construct an object in memory (.new
) or in the database (.create!
), so all that’s needed is a little mixin for some syntactic sugar:
module ModelFactory def make(label = '', caller_attrs = {}) if label.is_a? Hash caller_attrs = label label = '' end caller_attrs = caller_attrs.clone method = caller.find { |line| line =~ /\/spec\/models\// }.nil? ? :new : :create! id = caller_attrs[:id] if id if method != :new raise ArgumentError, "Can't specify :id for an object which is to be create!d in the database" end caller_attrs.delete :id else if method == :new id = 0 end end attrs = attrs method, label, caller_attrs attrs.merge! caller_attrs instance = send method, attrs if id instance.id = id end instance end def affix(label, value) label = label.to_s label.empty? ? value : label + '_' + value end end
Here’s an example of a class that extends the mixin and implements the attrs method to tell the module the default attributes with which the object should be created. It takes only a touch more effort than describing the object in a DSL, and being real code it’s much more flexible:
class Photo extend ModelFactory def self.attrs(method, label, caller_attrs) now = Time.now attrs = { :flickrid => affix(label, 'photo_flickrid'), :farm => '0', :server => 'server', :secret => 'secret', :dateadded => now, :lastupdate => now, :seen_at => now, :game_status => 'unfound', } if ! caller_attrs[:person] attrs[:person] = Person.make affix(label, 'poster') end attrs end end
The default usage is the same as Machinist’s nice syntax:
person = Photo.make
There is only one .make
method, however. It examines the call stack to see if this is a model spec or not and constructs the object using .create!
if it is and .new
if it is not. (Incidentally, isn’t the repeated punctuation in .nil? ? :new : :create!
amusingly hard to write and read?) This is a big win; in an earlier version with separate .make
and .make!
methods I often caught myself using the wrong one for the type of spec at hand.
Specifying custom values works like you’d expect as well:
photo = Photo.make :flickrid => '8675309'
To make a couple of photos with unique attributes, just give each a different label:
photo1 = Photo.make 1 # :flickrid => '1_photo_flickrid' photo2 = Photo.make 2 # :flickrid => '2_photo_flickrid'
(I gave my Photos only one labeled field, but of course you can label any field you like.) Note the argument juggling which lets you specify a label, explicit attribute values or both. Note also that Photo’s implementaton of .attrs
gives the Photo a labeled Person. Labels accumulate, so this scales up nicely to more complex objects, for example a Guess which has a Person (the guesser) and a Photo which has a different Person (the poster).
Finally, .make
also provides a default non-nil ID for objects not saved in the database and allows the user to override it. Both of those things just mean setting the ID immediately after creating the object with .new
, since ActiveRecord ignores an ID set in .new
. If the object is to be saved in the database, we stay out of ActiveRecord’s way and let it fill the ID on saving.
No rocket surgery here, just YAGNI and relentless refactoring. There’s nothing here I don’t need, and everything I do.
Update, three years later:
You knew the good times couldn’t last. I eventually decided that ModelFactory
‘s feature of using a label provided by the test writer is not useful often enough– I’m more likely to debug a test failure from my knowledge of what I just changed than by reading object attributes — and having to specify the label to prevent uniqueness constraint violations is annoying. factory_girl’s standard approach of differentiating attributes with sequences is good enough, and its exotic features like callbacks are sometimes very useful. So it’s back to the standard solution for me. Good exercise though.
By moving away from ModelFactory
I’m losing the feature of allowing database creation only in model specs. That should be easy enough to reinstate in factory_girl with careful configuration, which I’ll post on as soon as I get to it.
Leave a Reply