Testing the Right Stuff
I’m going to take a slightly different tack here, and review some of the unit tests in rails itself. They show up two common anti patterns, spurious assertions and coupling your tests to the implementation.
Perhaps the biggest benefit of a suite of unit tests is that they can provide a safety net, preventing you from accidentally adding new bugs or introducing regressions of old bugs. With a large codebase, the unit tests can also help new developers understand your intent, though they’re no substitute for comments. However if you’re not careful with what gets included in your test cases, you can end up with a liability.
Be careful what you assert
Whenever you add an assertion to your test suite you’re sending a signal to future developers that the behaviour you’re asserting is both intentional and desired. Future developers who try to refactor your code will see a failing test, and either give up, or waste time trying to figure out if the assertion is ‘real’ or whether it was merely added because that’s what the code happened to do at present.
For an example, take the test_hatbm_attribute_access_and_respond_to from associations_test.rb , especially the assertions that the project responds to access_level= and joined_on=. Because of the current implementation of respond_do?, those assertions pass. But should they?
In reality while those values will get stored in the object, they’ll never be written back to the database. This is a surprising result for some developers, and removing those accessor methods would go a long way to helping avoid some frustrating moments.
Mock and Stub with care
Mock object frameworks like flexmock and mocha make it really easy to test how your code interacts with another system or a third party library. However you should make sure that the thing that you’re mocking doesn’t merely reflect the current implementation of a method. To take a case from rails, take a look at setup_for_named_route in routing_test.rb.
It takes the seemingly sensible approach of building a stubbed-out implementation of url_for instead of trying to build a full implementation into the test cases. The stubbed version of url_for simply returns the arguments it was passed, this makes it extremely easy to work with and to test.
The problem is not with stubbing out the method, but in the way it is used in all the named route test cases. Take test_named_route_with_nested_controller.
1 2 3 4 5 6 7 |
def test_named_route_with_nested_controller rs.add_named_route :users, 'admin/user', :controller => '/admin/user', :action => 'index' x = setup_for_named_route.new assert_equal({:controller => '/admin/user', :action => 'index', :use_route => :users, :only_path => false}, x.send(:users_url)) end |
The strange hash value you see in the assertion is the result of the named route method calling url_for, and returning that. The current implementation of the named route helpers does this, but what if you wanted to implement a new version of named routes which completely avoids the costly call to url_for? Every single named route test fails, even though applications which use those methods will work fine.
In this situation you have two options, you could make your tests depend on the full implementation of url_for. This would probably slow down your test cases, and require a lot more setup code, but because the return values are correct you’re not likely to impede future refactoring.
The other option is to use different stubs for every test case. Leaving you with something like this:
1 2 3 4 5 6 7 8 |
def test_named_route_with_nested_controller rs.add_named_route :users, 'admin/user', :controller => '/admin/user', :action => 'index' generated_url = "http://test.named.routes/admin/user" x = setup_for_named_route.new x.stubs(:url_for).returns(generated_url) assert_equal(generated_url, x.send(:users_url)) end |
Doing this for each and every test case is going to be quite time consuming and make your test cases extremely verbose. As with all things in software you’ll have to make a judgement call on this trade off and make a choice between coupling or verbosity.
Whatever approach you choose, just remember that misleading test ‘failures’ can slow down refactoring, and end up reducing your ability to respond to change. As satisfying as ‘100% coverage’ or 2:1 ratios may be, don’t blindly add assertions or mock objects just to satisfy a tool. Every line in your test cases should be there for a reason, and should be placed there with just as much care as you’d use for a line of application code.
SignOut: Part 3
In addition to the REST refactoring for SignOut, we thought we’d cover a few more changes we’d suggest.
The biggest thing which jumped out at me was that SignOut has no tests. If you’ve never written a test in rails all the different tools and frameworks available can make it seem quit daunting. It’s often hard to tell if you should you use rspec, heckle, autotest, rcov and where to start. So in this article we’ll take the set_away action from EmployeeController and write a few tests for it, using just the stuff you get for free with rails.
The action as it stands has some reasonably involved code for handling decoding time_back from the request parameters:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
def set_away @employee = Employee.find(params[:id]) if params[:date][:meridian] == "PM" if params[:date][:hour].to_i == 12 hour = params[:date][:hour] else hour = (params[:date][:hour].to_i + 12).to_s end else if params[:date][:hour].to_i == 12 hour = "0" else hour = params[:date][:hour] end end time_back = Time.mktime(params[:date][:year], params[:date][:month], params[:date][:day], hour, params[:date][:minute] ) @employee.update_attributes(:reason => params[:employee][:reason], :time_out => Time.now, :time_in => time_back) end |
So there are four distinct cases that we need to ensure are tested:
- A Meridian of “PM” and an hour of 12
- A Meridian of “PM” and some other hour
- A Meridian of “AM” and an hour of 12
- A Meridian of “AM” and some other hour
First up we need some fixtures data for an employee, lets pretend I work there and create a test fixture for koz:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
koz: id: 1 firstname: Michael lastname: Koziarski company_email: koz@example.com personal_email: michael@koziarski.com extension: 665 cellphone: +64 21 555 1337 homephone: +64 4 555 1337 time_out: time_in: reason: created_on: <%= 5.days.ago.to_date.to_s(:db) %> modified_on: <%= Date.today.to_s(:db) %> initials: MAK |
Now the tests in EmployeeControllerTest:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class EmployeeControllerTest < Test::Unit::TestCase def setup @controller = EmployeeController.new @request = ActionController::TestRequest.new @response = ActionController::TestResponse.new end # Ensure that the employees table gets reloaded as needed fixtures :employees # we can DRY up the test data by providing a default date #and then just overriding what we need def default_date {:meridian=>"PM", :year=>now.year, :month=>now.month, :day=>now.day, :minute=>"0", :hour=>"12"} end # Test the first case, PM and 12 def test_set_away_for_pm_and_12_oclock # the default data matches this test case, post :set_away :id=>employees(:koz).id, :date=>default_date # ensure no errors ocurred assert_response :success # reload our fixture to match the database changes koz = employees(:koz).reload # make sure the time_back attribute has been set assert time_back = koz.time_back assert_equal 0, time_back.minute # 12PM is 12 midday, so the hour should be 12 assert_equal 12, time_back.hour # We provided a reason here, make sure the controller has set it assert_equal "Because", koz.reason end end |
We could continue to write tests like that, but instead we can get a little clever and let ruby write the tests for us:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
def default_date {:meridian=>"PM", :year=>now.year, :month=>now.month, :day=>now.day, :minute=>"0", :hour=>"12"} end TEST_SCENARIOS= {0=>{:hour=>"12", :meridian=>"AM"}, 5=>{:hour=>"5", :meridian=>"AM"}, 18=>{:hour=>"6", :meridian=>"PM"}, 12=>{:hour=>"12", :meridian=>"PM"}} TEST_SCENARIOS.each do |hour, hash_overide| # Make sure we give our tests useful names, otherwise it's hard to tell what failed name = "test_with_hour_#{hash_overide[:hour]}_and_meridian_#{hash_overide[:meridian]}" define_method(name) do post :set_away :id=>employees(:koz).id, :date=>default_date.merge(hash_override) # ensure no errors ocurred assert_response :success # reload our fixture to match the database changes koz = employees(:koz).reload assert time_back = koz.time_back assert_equal 0, time_back.minute # Make sure the hour matches our expected hour assert_equal hour, time_back.hour end end end |
Now you can refactor the set_away action without having to worry about introducing new date related bugs.

