Verifying Doubles with ActionMailer in Rails 4
Verifying doubles came out in RSpec 3, and it’s something that always sounded like such a brilliant yet obvious idea - make sure methods you stub actually exist. One friend in particular really stressed how many times this could have saved his tests from false positives. Sure, I thought, but usually I’d just modify something in the real code or modify the expectation to make sure it broke/worked in predictable ways, and move on. Then along came the perfect scenario while working on a little side project.
Below is the original spec and the corresponding controller method it's testing. (Things aren't especially DRY and a bunch of expectations are crammed into a single test to make things more explicit for this post.)
# spec
it 'sends the admin a message about a new order' do
controller.stub(:params).and_return(fake_full_params)
expect(AdminMailer).to receive(:order_confirmation).with(fake_email_params).and_return(Mail::Message.new)
allow_any_instance_of(Mail::Message).to receive(:deliver)
fake_stripe_success_path
expect(subject.status).to eq(200)
end
# controller
def create
# create a customer, charge, send confirmation, check for errors, etc
AdminMailer.order_confirmation(email_params).deliver
end
Since the AdminMailer already existed with other methods in it, I ran this spec and it passed - after all, AdminMailer received #order_confirmation with my fake params, and a mock stubs the method too, so nothing happened when it got called (and there's no #and_return chained to the expectation).
Now, I knew that #order_confirmation wasn’t implemented on AdminMailer yet - that was what I set out to do. Fast forward to getting smacked in the face with why I should be using verifying doubles and stop trying to keep even more state in my mind - it’s bad to cram too much state into objects and code, why force it on your poor brain?
Verifying Doubles to the Rescue.. Almost
So that I could use a double in my expectation, I changed things to the following:
# spec
it 'fires two mailers' do
admin_mailer_double = class_double(AdminMailer)
controller.stub(:admin_mailer).and_return(admin_mailer_double)
controller.stub(:params).and_return(fake_full_params)
allow_any_instance_of(Mail::Message).to receive(:deliver)
expect(AdminMailer).to receive(:order_confirmation).with(fake_email_params).and_return(Mail::Message.new)
# stubbed success from Stripe API and other checks
fake_success
expect(subject.status).to eq(200)
end
# controller
def create
# do other stuff
admin_mailer.order_confirmation(email_params).deliver
end
private
def admin_mailer
AdminMailer
end
But wait! I looked back at my ActionMailer class and realized that methods added to ActionMailer classes aren’t actually class methods, despite being called that way! Some more background in this post, and how ActionMailer in Rails 4 handles it here.
The Fix
Fortunately, the brilliant folks behind RSpec have considered this, and there are instance, class, and object doubles. There’s more too, see https://relishapp.com/rspec/rspec-mocks/v/3-5/docs/verifying-doubles. The final, passing spec that actually verifies the double is below. The key change to use an object_double is bolded -
context 'success path' do
let(:admin_mailer_double) { object_double(AdminMailer, :order_confirmation => Mail::Message.new) }
before(:each) do
controller.stub(:params).and_return(fake_full_params)
controller.stub(:admin_mailer).and_return(admin_mailer_double)
allow_any_instance_of(Mail::Message).to receive(:deliver)
end
it ‘sends an admin confirmation email’ do
expect(admin_mailer_double).to receive(:order_confirmation).with(fake_email_params).and_return(Mail::Message.new)
fake_stripe_success
expect(subject.status).to eq(200)
end
end
After making that change, I got the failure I expected and was back in the merry TDD path I enjoy so much. I implemented the method and was all set.
Failures:
1) CartsController#create success path sends a an admin confirmation email
Failure/Error: expect(admin_mailer_double).to receive(:order_confirmation) #.with(fake_email_params).and_return(Mail::Message.new)
AdminMailer does not implement: order_confirmation
# ./spec/controllers/carts_controller_spec.rb:44:in `block (4 levels) in '
Kudos to the RSpec team for making life so much easier, and code so much stronger, for Rails developers everywhere. Hope this helps someone out there!