Tuesday, June 27, 2006

Ruby on Rails Unit Tests

Updated following multiple comments requesting examples and questions on where to put AR::Base subclass tests.

Everyone (who reads this blog anyway) knows that you should not cross boundaries while unit testing. Unfortunately, Ruby on Rails seems to believe otherwise. This is evident by the fact that the test:units rake task has the pre-requisite db:test:prepare. Additionally, if you use script/generate to create a model, it creates a [model].yml fixture file and a unit test that includes a call to the fixtures class method. Rails may be opinionated, but that doesn't mean I have to agree with it.

With a minor modification you can tell Rails not to run the db:test:prepare task. You should also create a new test helper that doesn't load the additional frameworks that you will not need. I found some of the code for this from reading a great book, Rails Recipes, by Chad Fowler.

You'll need to add a .rake file to /lib/tasks. The file contains one line:
Rake::Task[:'test:units'].prerequisites.clear
Additionally, you'll need to create a new helper file in /test. I named my file unit_test_helper.rb, but the file name is your choice.
ENV["RAILS_ENV"] = "test" 
require File.expand_path(File.dirname(__FILE__) + "/../config/environment")
require 'application'
require 'test/unit'
require 'action_controller/test_process'
require 'breakpoint'

class UnitTest
def self.TestCase
class << ActiveRecord::Base
def connection
raise InvalidActionError, 'You cannot access the database from a unit test', caller
end
end
Test::Unit::TestCase
end
end

class InvalidActionError < StandardError
end
As you can see, the unit_test_helper.rb only requires what is necessary; however, it also changes ActiveRecord::Base to throw an error if you attempt to access the connection from a unit test.

I included this test in my codebase to ensure expected behavior.
require File.dirname(__FILE__) + '/../unit_test_helper'

class AttemptToAccessDbThrowsExceptionTest < UnitTest.TestCase
def test_calling_the_db_causes_a_failure
assert_raise(InvalidActionError) { ActiveRecord::Base.connection }
end
end
Update (Again):
We have been using this style of testing for several months now and have over 130 tests at this point and our tests still run in less than a second.

This decision does carry some trade-offs though. First of all, it becomes a bit more work to test ActiveRecord::Base subclasses in your unit tests. I'm comfortable with the small amount of extra work since it results in a significantly faster running test suite.

Also, if you need to use a AR::Base class as a dependency for another class, you will need to mock or stub the AR::Base class. This generally requires using Dependency Injection or a framework such as Stubba. For example, if you have a method that returns an ActiveRecord::Base subclass you can mock the new call and return a stub instead.
class AccountInformationPresenter

def account
Account.new
end

end

class AccountInformationPresenterTest

def test_account_returns_a_new_account
Account.expects(:new).returns(stub(:name=>'jay'))
AccountInformationPresenter.new.account
end

end
In the above code, mocking the new call on Account prevents an unnecesary database trip.

For an example of what our unit tests look like here are some tests and the classes that the tests cover.
require File.dirname(__FILE__) + '/../../../unit_test_helper'

class SelectTest < Test::Unit::TestCase
def test_select_with_single_column
assert_equal 'select foo', Select[:foo].to_sql
end

def test_select_with_multiple_columns
assert_equal 'select foo, bar', Select[:foo, :bar].to_sql
end

def test_date_time_literals_quoted
date = DateTime.new(2006, 1, 20, 13, 30, 54)
assert_equal "select to_timestamp('2006-01-20 13:30:54', 'YYYY-MM-DD HH24:MI:SS')", Select[date].to_sql
end

def test_select_with_symbol_and_literal_columns
assert_equal "select symbol, 'literal'", Select[:symbol, 'literal'].to_sql
end

def test_select_with_single_table
assert_equal 'select foo from foo', Select[:foo].from[:foo].to_sql
end

def test_select_with_multiple_tables
assert_equal 'select column from bar, foo',
Select[:column].from[:foo, :bar].to_sql
end
end

require File.dirname(__FILE__) + '/../../unit_test_helper'

class TimeTest < Test::Unit::TestCase
def test_to_sql_gives_quoted
t = Time.parse('2006/05/01')
assert_equal "to_timestamp('2006-05-01 00:00:00', 'YYYY-MM-DD HH24:MI:SS')", t.to_sql
end

def test_to_pretty_datetime
d = Time.parse("05/10/2006")
assert_equal "05-10-2006 12:00 AM", d.to_pretty_time
end
end

class Select < SqlStatement
class << self
def [](*columns)
unless columns.select { |column| column.nil? }.empty?
raise "Empty Column in #{columns.inspect}"
end
self.new("select #{columns.collect{ |column| column.to_sql }.join(', ')}")
end
end

def from
@to_sql += " from "
self
end

def [](*table_names)
@to_sql += table_names.collect{ |table| table.to_s }.sort.join(', ')
self
end

end

class Time
def to_sql
"to_timestamp('" + formatted + "', 'YYYY-MM-DD HH24:MI:SS')"
end

def to_pretty_time
self.strftime("%m-%d-%Y %I:%M %p")
end

private

def formatted
year.to_s + "-" + month.pad + "-" + day.pad + " " + hour.pad + ":" + min.pad + ":" + sec.pad
end
end

9 comments:

  1. Anonymous11:30 PM

    Can you post an example test or two? Is there a way to simulate the AR.save so that validations and callbacks are fired?

    TIA from a newb.

    ReplyDelete
  2. Anonymous10:32 AM

    Testing ActiveRecord classes needs to happen in the functional tests since they are tightly coupled to the database.

    In the last Rails application I wrote we had several classes that did not inherit from ActiveRecord::Base. These classes contained behavior that needed to be tested. The behavior of those classes had nothing to do with the database. Those are the classes that I test in my /test/unit folder.

    The value in this practice comes from the fact that you should be running the tests as often as possible. And, often I only want to run the unit tests, for performance reasons, until all my unit tests pass. When all my unit tests pass I run the longer running tests (functional and integration).

    ReplyDelete
  3. Anonymous1:50 PM

    This is interesting. The tight coupling to the DB bugs me as well, but I view that as a problem related to testing models. The approach I've taken is to accept the fact that the model tests need a DB, but seek out ways to decouple the controller tests from the real models (and therefore from the DB).

    I'm working on an acts_as_mock plugin to do this. It works some of the time. I'll post it somewhere when it works all (or at least most) of the time.

    I can definitely see the value in running tests against non-AR models though, so this post is much appreciated.

    ReplyDelete
  4. Anonymous11:03 PM

    I'd like to see an example test as well. I went through all of this and then created a Person model. A simple Person.new call blows up, because it has to make a connection to the db to see what properties it has. Am I missing something? I'd like to run my unit tests without hitting the DB, but it seems necessary if you're using an AR model - even if you don't save or load it.

    Do you mean this only for classes that don't inherit from AR::Base?

    I don't understand why you'd test AR:Base subclasses from your functional tests, assuming you keep most of your domain logic in the model.

    ReplyDelete
  5. Anonymous11:15 PM

    Pat:

    I'll post an update which should answer your question.

    ReplyDelete
  6. Anonymous7:33 PM

    What I'd like to see is an example session of using rake to carry out various tasks. I've created my 130 test cases, so now what are the steps I do to run the regression tests prior to checking in my code? Sure, I can get a list of rake tasks, but what does "db:test:prepare" *really mean*?

    ReplyDelete
  7. Anonymous2:53 PM

    Mocks and stubs to the rescue?

    http://rspec.rubyforge.org/

    ReplyDelete
  8. Anonymous2:43 PM

    Nice! I'm glad somebody else noticed the problem with test:units hitting a database.

    Unfortunately I'm still kind of learning Rails, and I'm not brave enough to introduce this into production code right now. But if you want us to all spam DHH asking him to fix this in Rails, we'd be happy to. :-)

    ReplyDelete
  9. Anonymous5:15 AM

    I generally like this approach and use it in my own code, but don't you think that this requires you to do much more stubbing/mocking, which in turn induces tight coupling with the tested class? I wrote about it a little bit more in this entry. Any thoughts?

    ReplyDelete

Note: Only a member of this blog may post a comment.