Sunday, June 17, 2007

DRYing up Unit Test Preconditions and Postconditions

It's good practice in unit testing to assert any preconditions (aka assumptions) relevant to the test and the expected postconditions (aka side-effects). I had a unit test that went something like this:
class TaggableTest < Test::Unit::TestCase
  fixtures :tags, :items
  def test_tagging
    one = items(:one)
    one.tag_list = "tag1, tag2, tag3, tag4"
    assert_equal 0, one.tags.size
    assert_equal 3, Tag.count
    assert_equal 0, Tagging.count
    one.save
    assert_not_nil one.id
    assert_equal 4, Tagging.count
    assert_equal 4, Tag.count
    assert_equal 4, one.tags.size
  end
I didn't care for this code because of the redundancy of the assert equals. So I added some new asserts to TestCase in test_helper.rb and was able to convert it into the following:
  def test_tagging
    one = items(:one)
    one.tag_list = "tag1, tag2, tag3, test4"
    assert_changed(lambda {one.tags.size},
                   lambda {Tag.count},
                   lambda {Tagging.count},
                   :from => [2, 3, 2],
                   :to => [4,4,4]) { one.save } 
  end
assert_changed will execute the code blocks passed in prior to the execution of the primary block and again afterwards. It will then compare those values to the from and to values (optionally) passed in. If you don't pass in both :from and :to, assert_changed merely validates that the values changed.
  private
  def singleton_maybe(s)
    return s[0] if s.is_a?(Array) and s.size == 1
    return s
  end
  
  public
  def assert_array_or_svo_equal(expected, actual)
    expected = singleton_maybe(expected)
    actual = singleton_maybe(actual)
    assert_equal expected, actual
  end

  def assert_changed(*expressions)
    options = {}
    options.update(expressions.pop) if expressions.last.is_a?(Hash)
    initial = expressions.map {|e| e.call}
    assert_array_or_svo_equal options[:from], initial if options.has_key?(:from)
    yield
    subsequent = expressions.map {|e| e.call}
    initial.each_with_index { |i, n| assert_not_equal i, subsequent[n] } if not (options.has_key?(:to) and options.has_key?(:from))
    assert_array_or_svo_equal options[:to], subsequent if options.has_key?(:to)
  end
  def assert_unchanged(*expressions)
    initial = expressions.map {|e| e.call}
    yield
    subsequent = expressions.map {|e| e.call}
    initial.each_with_index { |i, n| assert_equal subsequent[n] }
  end