This post is based on the talk I presented at the very awesome Magrails conference in Manchester last week - video is on the way soon. This really was a great event, well organised and well attended. I highly recommend keeping an eye on what the guys have got planned for next year.
I love testing now, but I wasn't always this way. Although I could instinctively understand the benefits of writing tests, I never properly understood the benefits of test driven development until around 18 months ago when working on my first gem for On the Beach. At this point in time, we were suffering under the weight of legacy code. Developing with tests was near impossible. Although we had test suites, they were slow and clunky, spending hours (yes, hours) working through the code and hammering the database. Sadly for all that effort, they were mostly just testing Rails itself.
Developing this gem I discovered something simple but extremely powerful: tests can be fast as hell. Fast as hell tests can be run evey time I make a change, and their failure can drive my next line of code. It sounds so obvious, but I'd never really experienced speed like this. At the time it was a revelation, and it let me find a coding rhythm I never knew existed. The general background noise in my head fell away as I stopped trying to juggle 20 things at once, I just followed the path I'd laid for myself with my tests and relaxed - I could code faster, work better and I even had to start using pomodoros to remind myself to take a break.
Going back to a rails project after this experience was pure pain.
Straight away my rhythm was shot, and although I tried using workarounds such as autotest, I found little solace. Frustrated with waiting for my tests to return with a failure I would forge ahead, anticipating the failure and writing the code based on my prediction - when my tests inevitably turned red, I had to backtrack 4 or 5 steps and try to understand where I had gone wrong. This wasn't TDD.
Over the last year we've made some massive improvements to our test
suites, but we still have a way to go. Even now the application I spend probably 90% of my day working in at On the Beach has a test suite that runs in around 10 minutes, and running a single spec will take around 15 seconds. 15 seconds is way too long.
Any creative or cognitive process will suffer dramatically from an interruption. Making these tests run faster is imperitive, to me it's the same reason I still keep an ancient Fostex 4 Track kicking around ready to record guitar - waiting for my laptop to power up is enough time to completely forget what I was just about to play.
So how can we make this better?
After watching the speed of Gary Bernhardt's tests on Peepcode, and then later on his Destroy All Software screencasts, I began working some of these ideas into any new code I was writing, and after seeing Corey Haines talk at GoGaRuCo I knew it was time to introduce these ideas to the rest of the team.
So here's a single spec running in the application I mentioned, which
for the record is a Rails 2.3 application running Rspec 1.3
require 'spec_helper'
describe "Nothing" do
# nada
end
15.741 seconds total
So what's taking 15 seconds here? spec_helper - it simply requires our rails environment - the whole thing, no matter which parts of it we actually need.
spec_helper is the best way to write really slow painful specs, because it removes your ability to intelligently choose what you're loading
Gary Bernhardt - Fast Tests With and Without Rails
So let's try this running the same test without spec_helper:
describe "Nothing" do
# nada
end
0.186 seconds total
So by avoiding loading Rails, we get an instant saving of 15 seconds
So here's the question - for our unit tests, why are we loading the
entire rails environment? In our case, it is because our business logic is in our Active Record models, and we need Rails to run tests against classes that inherit from ActiveRecord::Base. But of course by bundling our business logic in with our Active Record models we're
breaking the Single Responsibility Principle.
Ok so Active Record already breaks this principle, but that's the Active Record trade off - the trouble with Rails is that it implicitly suggests that we need a 1:1 mapping between our classes and our database tables. This can lead to low cohesion, and code that's difficult to test. When we find ourselves needing to create multiple objects and establish relationships in order to test some business logic in a unit test, it should be raising red flags all over the place - our tests are telling us that the design is wrong, and we need to change it.
A better approach would be to isolate the business rules from the active records and isolate the views from the models. This wouldn't violate any rails idioms, and would make rails applications a lot more flexible and maintainable.
Robert C. Martin, Stack Overflow
4 Steps
So with all that in mind, these are the 4 steps we have started to implement:
- Extract business logic into modules
- Extract domain objects into classes
- Mixin and delegate
- Test in isolation
The starting point
So here's a practical example, inspired by Corey Haines' demo at
GoGaRuCo but also based on real world code from one of our applications.
class Basket < ActiveRecord::Base
has_many :basket_items
def total_discount
basket_items.collect(&:discount).sum
end
end
The test
Here we're creating 3 ActiveRecord objects in order to test the behaviour. By including Rails in our unit tests, we're subconsciously encouraging ourselves to test Rails.
require 'spec_helper'
describe Basket do
context "total_discount" do
let(:basket) { Basket.create! }
let(:basket_items) {
[
BasketItem.create!(:discount => 10),
BasketItem.create!(:discount => 20)
]
}
it "should return the total discount" do
basket = Basket.create!
basket.basket_items = basket_items
basket.total_discount.should == 30
end
end
end
7.092 seconds to run
And here is the pain - any change to code comes with a penalty of 7
seconds to run the test.
time rspec spec
.
Finished in 0.24435 seconds
1 example, 0 failures
rspec spec 7.092 total
Extract behaviour into modules
The first option here is to extract the behaviour into module. To calculate a discount we don't need a Basket object, we can use any object, as long as it responds to basket_items with an array, and each object in that array responds to discount with an integer.
module DiscountCalculator
def total_discount
basket_items.collect(&:discount).inject(:+)
end
end
Mixin
class Basket < ActiveRecord::Base
has_many :basket_items
include DiscountCalculator
end
And the test
The test is becoming simpler. By using a module we can include into any class, we don't need to load our Rails environment. To test, we can create a fake class, mixin the module, and test the behaviour in
isolation using stubs. As long as the stub responds to basket_items with an array, and each stub in that array responds to discount with an integer, that's all we need.
require 'discount_calculator'
class FakeBasket
include DiscountCalculator
end
describe DiscountCalculator do
context "#total_discount" do
it "should return the total discount" do
basket = FakeBasket.new
basket_items = [stub(:discount => 10),
stub(:discount => 20)]
basket.stub(:basket_items) { basket_items }
basket.total_discount.should eq 30
end
end
end
0.350 seconds to run
And the benefits are huge, 7 seconds faster.
time rspec spec
.
Finished in 0.00121 seconds
1 example, 0 failures
rspec spec 0.350 total
Extract domain objects into classes
A second option is to delegate, and in this case we can make the code even more generic. The mixin example above required an object that responded to basket items, but in this example we can pass in the collection as an argument.
class DiscountCalculator
def total_discount(items)
items.collect(&:discount).inject(:+)
end
end
And delegate
class Basket < ActiveRecord::Base
has_many :basket_items
def total_discount
DiscountCalculator.new.
total_discount(basket_items)
end
end
And the test
The test here is even more concise, we only need to create an array of item stubs.
require 'models/discount_calculator'
describe DiscountCalculator do
context "#total_discount" do
let(:items) {
[
stub(:discount => 10),
stub(:discount => 20)
]
}
it "should return the total discount" do
calculator = DiscountCalculator.new
calculator.total_discount(items).should eq 30
end
end
end
0.342 seconds to run
And again the test is fast.
time rspec spec
.
Finished in 0.00101 seconds
1 example, 0 failures
rspec spec 0.342 total
The benefits
So to me, the benefits are clear, by taking this approach we can write highly cohesive code, with lightning fast tests. Once we hit that rhythm of test/code/test/code our tools have moved out of our way, and we can really let our tests guide our code. And that puts me right back in my happy coding place.
The final step
So after a Pomodoro's worth of red/green/refactor with awesomely fast
tests, there always has to be a final step - run the integration tests. Isolated unit tests are great, but if anything, they highlight the need for well written integration tests.
The demo code presented here is available on Github - please fork away - I'd really like to see how other people are approaching this. I'll be adding additional examples as I come across them, so far these include testing helpers without spec_helper, and including active_support core extensions