Another solution for disconnected unit tests

Update (9/5/07): My project has since switched over to UnitRecord for the reasons outlined by Dan Manges.

Along with many others, I believe that unit tests should have as few dependencies as possible and be very fast. In rails, this means disconnecting models from the database when running unit tests.

My project uses the following solution, which is derived from the solution posted at Jay’s blog: Rails: ActiveRecord Unit Testing part II

The difference is that this solution does not unbind and rebind the initialize method. Instead, it uses the inherited hook to include a module with the necessary code on each ActiveRecord::Base subclass. My team and I like this solution better since it doesn’t unbind and redefine methods, but rather uses ruby’s object model to insert code in the call hierarchy between the model and ActiveRecord::Base.

The full solution belongs in a file (unit_test_helper.rb) that is required by each unit test:

module DisconnectedModel
  def initialize(attributes={})
    self.class.stubs(:columns).returns(no_db_columns(attributes))
    super
  end

  def no_db_columns attributes
    return [] if attributes.nil?
    attributes.keys.collect do |attribute|
      sql_type = case attributes[attribute].class
      when " " then "integer"
      when "Float" then "float"
      when "Time" then "time"
      when "Date" then "date"
      when "String" then "string"
      when "Object" then "boolean"
      end
      ActiveRecord::ConnectionAdapters::Column.new(attribute.to_s, nil, sql_type, false)
    end
  end
end

class ActiveRecord::Base
  def self.inherited(cls)
    cls.class_eval do
      include DisconnectedModel
    end
  end
end

class << ActiveRecord::Base
  def connection
    raise 'You cannot access the database from a unit test'
  end
end

Models can be tested by passing in the columns and their values into the new method:

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

class DogTest < Test::Unit::TestCase
  def test_name
    dog = Dog.new(:name => "fido")
    assert_equal "fido", dog.name
  end
end

Models can even have their own initialize method as long as it calls super:

def test_translate_age_into_dog_years
  dog = Dog.new(:age => 3)
  assert_equal 21, dog.age
end

class Dog < ActiveRecord::Base
  def initialize(args={})
    human_age = args[:age] || 0
    super(args.merge(:age => human_age * 7))
  end
end