Making a mockery of ActiveRecord

This is long, so grab a drink

I had a good RSpec experience today (well, it is a weekday) that I thought was worth sharing.

If this is your first time reading about mocks, or if you’re just rusty, you may want to hit up this nice collection of info: Ruby Mock ObjectsThe setup:

  • An email Campaign.
  • A Message that represents what is arguably a template.
  • People and semi-dynamic Lists of people.
  • People can be connected to a Campaign directly via a Subscription (an opt-in), or they can simply be on a List. 
    (We won’t see the reasoning for that in this simplified example, but just pretend it makes sense.)

When Messages are ready to be delivered, they are told to generate EmailMessage records.
EmailMessages are connected directly to People, and represent what will eventually be sent as real emails.

If this seems involved, it’s because you haven’t seen the customer requirements, so stop complaining.

The traditional Rails solution to testing this would be to make fixtures for all of the above models, and any join models.
You would then load and roll back those fixtures numerous times, repetitively testing what you already know; namely, that your database service has been started properly.

This has a number of drawbacks, not least of which is that you are probably referring back to your fixtures as you write your tests, to make sure you get the names right, and that you pick the appropriate fixture for each scenario.

Enter Mocks.

Even in an alternate world where mocks were no more expressive than fixtures, they would be worth using simply to have your ‘setup’ information right there on screen as you write your specs. Luckily, they are a lot cooler than that.
I’ll take you through a tour of the various steps along the way to specifying the behavior of the EmailMessage generation process, ‘mock-first’.

Round One

Here is the first cut at the code.

 1 context "A Message being generated for delivery" do
 2   setup do
 3     @campaign = mock("campaign")
 4     @campaign.stub!(:is_a?).and_return(true)
 5     @campaign.stub!(:new_record?).and_return(false)
 6     @campaign.stub!(:id).and_return(rand(1000))
 7 
 8     @person = mock("person")
 9     @person.stub!(:is_a?).and_return(true)
10     @person.stub!(:new_record?).and_return(false)
11     @person.stub!(:id).and_return(rand(1000))
12 
13     @message = Message.new :name => "Hello, world", :campaign => @campaign
14     @list = mock("list")
15     @subscriptions = mock("subscriptions")
16   end
17 
18   specify "should create EmailMessages but not Subscriptions when include_subscribers is false" do
19     @message.should_receive(:lists).and_return([@list])
20     @list.should_receive(:people).and_return([@person])
21     @message.should_receive(:campaign).and_return(@campaign)
22     @person.should_receive(:campaigns).and_return([@campaign])
23     @message.generate.should == 1
24     @message.should_have(1).email_messages
25   end
26 
27   specify "should create EmailMessages and Subscriptions when include_subscribers is true" do
28     @message.include_subscribers = true
29     @message.should_receive(:lists).and_return([@list])
30     @message.should_receive(:campaign).twice.and_return(@campaign)
31     @list.should_receive(:people).and_return([@person])
32     @person.should_receive(:campaigns).and_return([])
33 
34     @person.should_receive(:subscriptions).and_return(@subscriptions)
35     @subscriptions.should_receive(:create).and_return(nil)
36     @campaign.should_receive(:people).and_return([])
37     @message.should_have(1).email_messages
38   end
39 end
40 

This is pretty decent, really. Probably fewer lines of code than the equivalent fixtures, and faster because we’re not hitting the database.
On the other hand, there’s ‘setup’ everywhere, and it’s hard to see what the actual expectations are.
@message.should_have(1).email_messages is like a needle in a haystack.

Also, I’ve got everything in a single ‘context’, even though quite literally there are two different ones in play here.
I simply didn’t want to have to repeat the ‘setup’ block in a second context, and got lazy.

Let’s fix that first, since maybe it’s at the heart of the problem.

Round Two

 1 module MessageSetup
 2   def setup_message
 3     @campaign = mock("campaign")
 4     @campaign.stub!(:is_a?).and_return(true)
 5     @campaign.stub!(:new_record?).and_return(false)
 6     @campaign.stub!(:id).and_return(rand(1000))
 7 
 8     @person = mock("person")
 9     @person.stub!(:is_a?).and_return(true)
10     @person.stub!(:new_record?).and_return(false)
11     @person.stub!(:id).and_return(rand(1000))
12 
13     @message = Message.new :name => "Hello, world", :campaign => @campaign
14     @list = mock("list")
15     @subscriptions = mock("subscriptions")
16   end
17 end
18 
19 context "A Message being generated without include_subscribers" do
20   include MessageSetup
21   setup { setup_message }
22 
23   specify "should create EmailMessages but not Subscriptions" do
24     @message.should_receive(:lists).and_return([@list])
25     @list.should_receive(:people).and_return([@person])
26     @message.should_receive(:campaign).and_return(@campaign)
27     @person.should_receive(:campaigns).and_return([@campaign])
28     @message.generate.should == 1
29     @message.should_have(1).email_messages
30   end
31 end
32 
33 context "A Message being generated with include_subscribers enabled" do
34   include MessageSetup
35   setup { setup_message }
36 
37   specify "should create Subscriptions and EmailMessages" do
38     @message.include_subscribers = true
39     @message.should_receive(:lists).and_return([@list])
40     @message.should_receive(:campaign).twice.and_return(@campaign)
41     @list.should_receive(:people).and_return([@person])
42     @person.should_receive(:campaigns).and_return([])
43 
44     @person.should_receive(:subscriptions).and_return(@subscriptions)
45     @subscriptions.should_receive(:create).and_return(nil)
46     @campaign.should_receive(:people).and_return([])
47     @message.generate.should == 1
48     @message.should_have(1).email_messages
49   end
50 end
51 

Here I’ve taken the ‘setup’ code, moved it into a module, and mixed it into the two contexts.
It looks a little showy, but at least it keep the setup code away from the specs.
Also, now that there are two separate contexts, the specs can be a little more readable.

All that being said, I’m still not happy. The ugly stubs required to fake out ActiveRecord are distracting, and appear in every context that needs to say:
@real_activerecord_model.some_association = some_mock

Under the hood, ActiveRecord checks to make sure the association is being handed a compatible object. We have to stub that to make this all work. Why are we bothering again? Test isolation. Future model changes that don’t affect this code shouldn’t break these specs. If you are dealing with six or seven model classes per spec, that is ridiculously easy to do.

What can we do about this boilerplate? Well, let’s make a helper.

Round Three

 1 module MessageSetup
 2   def setup_message
 3     mock_model :campaign
 4     mock_model :person do |m|
 5       m.should_receive(:campaigns).and_return([@campaign])
 6     end
 7     mock_model :list do |m|
 8       m.should_receive(:people).and_return([@person])
 9     end
10     @message = Message.new :name => "Hello, world", :campaign => @campaign
11   end
12 end
13 
14 context "A Message being generated without include_subscribers" do
15   include MessageSetup
16   setup { setup_message }
17 
18   specify "should create EmailMessages but not Subscriptions" do
19     @message.should_receive(:lists).and_return([@list])
20     @message.should_receive(:campaign).and_return(@campaign)
21     @message.generate.should == 1
22     @message.should_have(1).email_messages
23   end
24 end
25 
26 context "A Message being generated with include_subscribers enabled" do
27   include MessageSetup
28   setup do
29     setup_message
30     @subscriptions = mock("subscriptions")
31     @campaign.should_receive(:people).and_return([])
32     @person.should_receive(:subscriptions).and_return(@subscriptions)
33     @message.include_subscribers = true
34   end
35 
36   specify "should create Subscriptions and EmailMessages" do
37     @message.should_receive(:lists).and_return([@list])
38     @message.should_receive(:campaign).twice.and_return(@campaign)
39 
40     @subscriptions.should_receive(:create).and_return(nil)
41     @message.generate.should == 1
42     @message.should_have(1).email_messages
43   end
44 end
45 

Hey, that’s looking pretty good. “mock_model” says pretty directly what the mission is, and having it take a block avoids the need to repeat the name of the instance variable. 
In fact, there’s no longer a need to assign the mock return value to something, since, in typical ‘convention over configuration’ style, the mock automatically goes into an instance variable with the same name as the model.

How did we do? Let’s see what the “time” command has to say:

time drbspec spec/models/message_spec.rb

.............

Finished in 0.268692 seconds

13 specifications, 0 failures
drbspec spec/models/message_spec.rb  
0.08s user 
0.02s system 
11% cpu 
0.852 total

Under a second of wall-clock time isn’t half bad.

Here’s the helper code, which goes in the ‘EvalContext’ of spec_helper.rb.
I’ll probably be adding some more features before submitting it as a patch, but you’re welcome to it.

 1 def mock_model(name)
 2   name = name.to_s
 3   m = mock(name)
 4   instance_variable_set("@#{name}", m)
 5   m.stub!(:id).and_return(rand(10_000))
 6   m.stub!(:new_record?).and_return(false)
 7   klass = name.singularize.camelize
 8   m.send(:__mock_handler).instance_eval <<-CODE
 9           def @target.is_a?(other)
10             other == #{klass}
11           end
12           def @target.class
13             #{klass}
14           end
15   CODE
16   yield m if block_given?
17 end