Testing Data Migrations in Rails

Article summary

When working on a Rails project, you will inevitably need to move data around in your database. Some join table value will need to be moved into its own table or what have you. When approaching these kinds of migrations, there are two major complications: future-proofing and testing. In this post, let’s walk through an example migration.

Inserting Data

Rule of thumb: Do not use any ActiveRecord Models in a migration. As a project develops and changes, so do its classes. When building a migration, you want it to be reliable and reproducable. Migrations should behave exactly the same every time they are run. What if an after_save hook has been added? What if the table_name has changed? What if that class doesn’t even exist anymore?

We do know that migrations are tied to our schema.rb version and that we can depend on tables and columns, just not AR models. We use ar_class_for_table to dynamically build a namespaced ActiveRecord model for the given table.


module ArClassForTable
  def ar_class_for_table table, &blk
    klass = Class.new ActiveRecord::Base
    unique_module = Module.new
    ArClassForTable.const_set "Id#{ArClassForTable.unique_id}", unique_module
    unique_module.const_set table.to_s.classify, klass
    klass.table_name = table
    klass.class_eval &blk if block_given?
    klass
  end
  
  def self.unique_id
    @unique_id ||= 0
    @unique_id += 1
  end
end
ActiveRecord::Migration.extend ArClassForTable

In your migration, you can use it as a standard ActiveRecord model, but this one won’t shift out from under you with the rest of your codebase.


  user_klass = ar_class_for_table(:users)
  user_klass.where(...)
  user_klass.create(...)
  # etc..

Testing

Wrangling your database to be in just the right setup for a migration test can be tricky. I wish Rails had better baked-in harnessing for testing data migrations, but since I couldn’t find any, here are two options:

Never been squashed

If you have never squashed your migrations down, you still have all the migrations since the beginning of your project. Your test should look like this:

  1. Drop all tables.
  2. Migrate to the version just before the one you wish to test.
  3. Insert test data using ar_class_for_table or SQL.
  4. Run migration.
  5. Assert results using ar_class_for_table or SQL.

Squashed migrations

If you have squashed your migrations by removing old ones and relying solely on your schema.rb to “catch you up,” we need to do a little more work:

  1. Drop all tables.
  2. Load schema.rb.
  3. Migrate down to the version just before the one you wish to test.
  4. Insert test data using ar_class_for_table or SQL.
  5. Run migration.
  6. Assert results using ar_class_for_table or SQL.

Here are all the helpers needed to make the above testing plan work:


require 'spec_helper'
require Rails.root.join('db/migrate/20160712211943_populate_orgs_for_students')
describe MyMigrationThing, type: :migration do

  def drop_all_tables
    ActiveRecord::Base.connection.tables.each do |table|
      ActiveRecord::Base.connection.drop_table(table)
    end
  end

  def migrate(opts={})
    quietly do
      version = opts[:version] ? opts[:version].to_i : nil
      down = opts[:dir] == :down
      up = opts[:up] == :up
      if down
        ActiveRecord::Migrator.down(DB_MIGRATIONS_PATHS)
      elsif up
        ActiveRecord::Migrator.up(DB_MIGRATIONS_PATHS)
      else
        ActiveRecord::Migrator.migrate(DB_MIGRATIONS_PATHS, version)
      end
    end
  end

  def quietly
    old_verbose = ActiveRecord::Migration.verbose
    ActiveRecord::Migration.verbose = false
    ActiveRecord::Base.logger.silence do
      yield
    end
  ensure
    ActiveRecord::Migration.verbose = old_verbose
  end

  before do
    quietly do
      drop_all_tables
      load Rails.root.join('db/schema.rb')
      migrate version: THIS_TEST_VERSION
      migrate dir: :down
    end
  end

  after do
    drop_all_tables
    load Rails.root.join('db/schema.rb')
    migrate
  end

  it 'does a thing' do
    # insert test data

    migrate dir: :up

    expect(...)
  end

When dealing with the migration of production data, your code must be future-proof and well tested. I’ve presented a couple of options here. Leave your approaches in the comments.