I’m beginning my crusade to make Rails more testable. I’ll start off with a very small, but very useful plugin that makes testing ActiveRecord classes that use Observers painless.
If you just want the plugin
MIT license, copyright me, yadda yadda
My design lesson for the day
Consider the following Account class:
This bank lets account holders make overdraft withdrawals. They don’t just want to give money away though – at the very least they want to send notices to the account holder. We can stick that logic in the #debit method:
There are a couple problems with that. First of all, it violates the Single Responsibility Principle. The #debit method is not only responsible for changing the account balance, but also for sending overdraft notices. This is just a bad idea in general, and it’s immediately apparent when we try to test it:
The spec fails because #debit calls owner.email, and owner is nil. We can solve this by stubbing the owner:
That’s quite ugly…we’re setting up infrastructure, and we don’t even know why! The example suggests that it’s a basic debit method. So why on earth do we need to stub an owner object?
We can make things a little better by moving the delivery notice out of #debit and into a callback:
That’s better. At least the spec passes without any stubbing.
What happens when we want to add some more behavior? Let’s try generating an account ID based on the owner’s name and SSN:
This spec fails because the #check_account_overdraft callback checks to see if the balance is less than 0.
Clearly there is something wrong. We can’t even test simple account creation because it’s too coupled to the overdraft notification. If you think carefully, you’ll see that overdraft notification shouldn’t be an Account’s responsibility. It may be something the business needs, but it isn’t fundamental to the way an Account behaves. We need to move that behavior elsewhere.
One way is to introduce a Service Layer. A Service Layer allows you to sensibly split up business logic. In our debit example, there are two pieces of business logic. First there’s actually debiting the account, which is domain logic and should be handled by the Account class. Then we have overdraft notification, which is another important business process, but is fundamentally separate from the Account.
We can write an AccountService which wraps it all up:
That works well, and is easy enough to test. It certainly helps us out a great deal by decoupling the Account class from overdraft notification.
There’s something about it I just don’t like though. My main concern is that it introduces another layer, which isn’t necessary for such simple behavior. Ruby is nice and clean so it’s probably not that big of a deal, but at the very least I’ll have to think about whether some behavior should go in the Account or in the AccountService. If it goes in AccountService, can I just edit AccountService directly, or should I decorate it with yet another service? I’d like to get rid of that conceptual overhead.
Observers are great when you want loosely coupled behavior driven by state changes. We can move the overdraft notification to an AccountObserver:
That works out nicely. Now the Account is responsible only for domain logic, and we have another object responsible for notifications.
There’s one problem though. The account ID generation spec blows up again. This is because Rails hooks the observers up as soon as it runs, so now every time an Account gets saved, the AccountObserver kicks off.
The whole point of using an Observer is to decouple the Account’s domain logic from related, but still separate, business logic. But Rails makes you think about Observers when you write tests for the Account.
Enter the No Peeping Toms plugin. It turns off Observers in your test environment, allowing you to write specs without worrying about the Observers – as it should be. Not only that, it also lets you turn on certain Observers for a block of code, letting you write specs for the Observers themselves.
Get it from http://github.com/patmaddox/no-peeping-toms/tree/master
Here’s a spec that shows the account ID generation, basic debit behavior, and hooking up an Observer: