Faster Ruby Testing: Only Test What Matters
02 Aug 2015Automated testing is important. Fast, exhaustive automated testing is even more important. Tests are responsible for ensuring the code you spend hours creating actually works. A great test suite can be a safeguard against bugs, a directional guide towards extending the code, and an accurate measurement of the codebase’s health. The key to writing good tests is understanding where pieces of responsibility begin and end. Maintaining small concise automated tests can make all the difference.
Speed is King
Aside from the general importance of short feedback cycles, a fast test suite will greatly improve your work-flow and mood. Conversely, a slow test suite can make your development cycle a living nightmare. How many unfortunate software artisans have dealt with something like:
Bleh! What a horrible thing to deal with. What, am I supposed to just wait 5 whole minutes every time I want to test my system? A test suite this slow is simply unacceptable.
So what could be causing this slowness? More than likely, a number of tests in this application are doing too much. To try and replicate and solve this problem, let’s assume we have a Ruby on Rails application we test using RSpec.
Testing Database Models is Slow
We can assume that there exists a very important class called MyAwesomeClass
:
class MyAwesomeClass < ActiveRecord::Base
def assign_and_save!
saved_on_the_weekend = weekend?
self.save!
end
def weekend?
Time.now.saturday? || Time.now.sunday?
end
end
If we wanted to test that the assign_and_save!
method works correctly, we might write a test like:
describe '#assign_and_save!' do
it 'assigns and saves' do
awesome_test = MyAwesomeClass.new
expect do
awesome_test.assign_and_save!
end.to change(MyAwesomeClass, :count).by(1)
expect(awesome_test.saved_on_the_weekend).to_not be_nil
end
end
These tests make sure that the model is saved correctly to the database and a value is assigned to saved_on_the_weekend
. However, the test code is overstepping the boundaries of the method it is testing. The assign_and_save!
method’s job is to simply assign a value to an object and then save it. It does not care about how the actual saving works, that is the job of other validations on the model and ActiveRecord
.
To avoid this, we can assert that the save!
method is called, which will not actually write to our database:
describe '#assign_and_save!' do
it 'assigns and saves' do
awesome_test = MyAwesomeClass.new
expect(awesome_test).to receive(:save!)
awesome_test.assign_and_save!
expect(awesome_test.saved_on_the_weekend).to_not be_nil
end
end
Voila! We have made the same assertions about our code and did not do any slow input/output operations.
Now I know what some of the more detail oriented Software Artisans reading this will initially think: “What about validation concerns with the model?” and “If you stub out save!
like that you can’t be sure it worked”. While these are totally valid points, there exists a simple solution for dealing with the uncertainty of the save:
The valid?
method
describe '#assign_and_save!' do
it 'assigns and saves' do
awesome_test = MyAwesomeClass.new
expect(awesome_test).to receive(:save!)
awesome_test.assign_and_save!
expect(awesome_test.saved_on_the_weekend).to_not be_nil
end
it 'is save-able' do
awesome_test = MyAwesomeClass.new
allow(awesome_test).to receive(:save!)
awesome_test.assign_and_save!
expect(awesome_test).to be_valid
end
end
With the new test assertion, we make sure that the model’s valid?
method returns true, ensuring the model will be saved properly.
The valid?
method is what ActiveRecord
uses before saving the model to the database. This method returns either true
or false
and writes the data to the database if true
was returned. Now the code is tested and ActiveRecord
’s’ validation errors are considered.
Not Perfect, but Good Enough
However, there is one more thing that this test will not catch: database specific uniqueness constraints. If your application uses a database to enforce uniqueness and not in ActiveRecord
validations, this testing method will fail. For that you will need a more robust integration tests which guard against duplicate data.
All that aside, not actually saving data will be fine for the 90% case which is most codebases.
Only Test What Matters
In this example, removing the testing of save!
resulted in a speed increase. However, the idea of only testing the crucial parts of a method is not only about speed, it is about encapsulation. If a method’s only responsibility is to call helper methods, that is the only thing that should be tested. The test should simply assert that the helper methods are called, not the logic within them. Following this pattern will help keep test code small, concise, readable, and input/output operations to a minimum.