Friday, December 29

Unit testing (Rails) ActiveRecord classes without a database

On Christmas Jay blogged about unit testing ActiveRecord classes:

class PhoneNumberTest < Test::Unit::TestCase
  Column = ActiveRecord::ConnectionAdapters::Column

  test "to_formatted_s returns US format" do
    PhoneNumber.stubs(:columns).returns([Column.new("digits", nil, "string", false)])
    number = PhoneNumber.new(:digits => "1234567890")
    assert_equal "(123) 456-7890", number.to_formatted_s
  end
end

We don't hit the database in unit tests. Having disabled database access on our unit tests, I started using the above approach to write some unit tests for some active record classes that did not have suitable unit tests. On writing a couple of tests though, it was clear this would be too cumbersome:

class PersonTest < Test::Unit::TestCase
  Column = ActiveRecord::ConnectionAdapters::Column

  test "full name concatenates" do
    Person.stubs(:columns)
          .returns(
          [Column.new("first_name", nil, "string", false), 
           Column.new("middle_initial", nil, "string", false), 
           Column.new("last_name", nil, "string", false)])
    person = Person.new(:first_name => "Ross", 
                        :middle_initial=>"J", 
                        :last_name=>"Pettit")
    assert_equal "Ross J Pettit", person.full_name
  end
end

Jay suggested a fluent interface:

class PersonTest < Test::Unit::TestCase
  test "full name concatenates" do
    person = disconnected(Person).where(:first_name => "Ross", 
                                        :middle_initial=>"J", 
                                        :last_name=>"Pettit")
    assert_equal "Ross J Pettit", person.full_name
  end
end

Finally, here's how we implemented it:

class Test::Unit::TestCase
 class ActiveRecordUnitTestHelper
   attr_accessor :klass

   def initialize klass
     self.klass = klass
     self
   end

   def where attributes
     klass.stubs(:columns).returns(columns(attributes))
     instance = klass.new(attributes)
     instance.id = attributes[:id] if attributes[:id] #the id attributes works differently on active record classes
     instance
   end
      
      protected
   def columns attributes
     attributes.keys.collect{|attribute| column attribute.to_s, attributes[attribute]}
   end

   def column column_name, value
     ActiveRecord::ConnectionAdapters::Column.new(
  column_name, nil,
  ActiveRecordUnitTestHelper.active_record_type(value.class), 
  false)
   end
  
   def self.active_record_type klass
     return case klass.name
       when "Fixnum"         then "integer"
       when "Float"          then "float"
       when "Time"           then "time"
       when "Date"           then "date"
       when "String"         then "string"
       when "Object"         then "boolean"
     end
   end
 end

 def disconnected klass
   ActiveRecordUnitTestHelper.new(klass)
   end
end

3 comments:

Chad said...

I saw Jay's approach and was thinking the same thing. One of the larger maintainance projects I'm a part of would become too unwieldy immediately with the one for one approach.

Thanks for this snippet :) It will quickly find a comfortable spot in my rspec and test/unit code.

Good to see you posting on the ruby front as well :)

Brian Candler said...

Looking at the Column.new(...) style, I note that this is just repeating information which already exists, in the form of migrations. In fact, Rails conveniently leaves a db/schema.rb lying around which reflects the most recent schema of the database.

So this suggests an alternative approach for handling disconnected AR objects: read in db/schema.rb into a global data structure, and then override ActiveRecord::Base#columns so that it returns the column info from this.

Then, say, Person.new(:first_name=>'Ross') would be usable as a mock object without any additional work, automatically gaining the relevant accessors.

Muness Alrubaie said...

Brian,

Your comment couldn't be more timely - Dan Manges implemented this (as a plugin for now) and posted it today. See his blog post and .