The Fixture Debate

If any of the debates at the Mountain West RubyConf last March could be called “heated”, I’d say the hottest one came when we started to discuss unit tests and functional tests in Rails.  I might be reading my own story into things, but what I got out of the discussion was that there is a consensus surrounding the idea that unit tests are good, and we’re all glad that Rails ships with a testing suite, but the brittleness of testing with fixtures is something that has yet to be solved universally.

The Flexible Solution, Redux

I’ve approached this problem before and offered a “flexible fixtures” patch (twice) to the core team that never made it in.  (It’s ok, I’m not bitter… I just think it’s a higher priority than they do).  With that patch having long been laid aside, I offer here a simple fix that will add much of the same functionality with a small change (fewer than 20 lines of reasonable length) to your test_helper.rb file (see below).

What you get is the ability to specify the directory (i.e. the “context”) from which to load certain fixtures. For example, you might have several fixtures that are always loaded (e.g. drop-down box data or other basic data that is required just for your app to run) and then several scenarios or contexts that rely on that base level of data.

This is how your unit test would look using this “context_fixtures” call:

class AccountingRulesTest < Test::Unit::TestCase
  context_fixtures "base", :cities, :countries, :holidays
  context_fixtures "three_clients", :users, :accounts, :transactions

  def test_transactional_banking
    # ... etc.
  end

  # ... etc.

end

You can use context_fixtures in your functional tests this way, too.  I organize my test/fixtures directory into subfolders such as test/fixtures/base, and test/fixtures/three_clients, etc.  Note that this solution is not as flexible as the “flexible fixtures” patch, since it still requires that the fixture files map one-to-one to the tables in your database.  As usual, however, it’s ok to choose not to load data into some tables.

Keeping Your Deprecated Fixtures Around

If you’re in the middle of a project and you realize that you need these contexts in order to reduce the complexity of writing tests, what do you do with the test cases and data that are already working just wonderfully?  You don’t want to have to discard those.  Instead, you could create your own custom TestCase classes, like this:

module Test
  module Unit
    class DeprecatedFixturesTestCase < TestCase
      self.fixture_path =
        File.join(RAILS_ROOT,
                  "test", "deprecated_fixtures")
    end
  end
end

module ActionController
  class DeprecatedFixturesTestCase < TestCase
    self.fixture_path =
      File.join(RAILS_ROOT,
                "test", "deprecated_fixtures")
  end
end

After adding the above code to your test_helper.rb file,

  1. Rename the current “fixtures” directory to “deprecated_fixtures”, and
  2. Use this DeprecatedFixturesTestCase class as the superclass of all of your “deprecated tests”.

It took me just a minute or two to search and replace “TestCase” with “DeprecatedFixturesTestCase”. With that change, from here on out, just use the regular TestCase class for your new tests and use “context_fixtures” instead of “fixtures”.

Context Fixture Source Code for “test_helper.rb”

ENV["RAILS_ENV"] = "test"
env = File.dirname(__FILE__) + "/../config/environment"
require File.expand_path(env)
require 'test_help'

# Add a little more granularity to our fixture
# directories so that we can load some fixtures from
# one directory (context), and other fixtures from
# other directories.
class Fixtures < (RUBY_VERSION < '1.9' ? YAML::Omap : Hash)
  superclass_delegating_accessor :contexts
  self.contexts = {}

  alias context_free_initialize initialize
  def initialize(connection, table_name,
                 class_name, fixture_path,
                 file_filter = DEFAULT_FILTER_RE)
    if contexts.has_key?(table_name.to_s)
      fixture_path =
        File.join(RAILS_ROOT, "test", "fixtures",
                  contexts[table_name.to_s],
                  table_name.to_s)
    end
    context_free_initialize(
      connection, table_name,
      class_name, fixture_path,
      file_filter)
  end
end

module Test
  module Unit
    class TestCase
      self.use_transactional_fixtures = false
      self.use_instantiated_fixtures  = false

      # Provide the 'context_fixtures' class method
      # in conjunction with our modded Fixtures class
      # above.
      def self.context_fixtures(context, *fxtrs)
        fxtrs.each do |f|
          Fixtures.contexts[f.to_s] = context
        end
        fixtures *fxtrs
      end
    end
  end
end

Tags: , , ,