How do I test Rails migrations?

Ruby on-RailsDatabaseTesting

Ruby on-Rails Problem Overview


I want to test that certain conditions hold after running a migration I've written. What's the current best way to do that?

To make this concrete: I made a migration that adds a column to a model, and gives it a default value. But I forgot to update all the pre-existing instances of that model to have that default value for the new column. None of my existing tests will catch that, because they all start with a fresh database and add new data, which will have the default. But if I push to production, I know things will break, and I want my tests to tell me that.

I've found http://spin.atomicobject.com/2007/02/27/migration-testing-in-rails/, but haven't tried it. It's very old. Is that the state-of-the-art?

Ruby on-Rails Solutions


Solution 1 - Ruby on-Rails

Peter Marklund has an example gist of testing a migration here: https://gist.github.com/700194 (in rspec).

Note migrations have changed since his example to use instance methods instead of class methods.

Here's a summary:

  1. Create a migration as usual
  2. Create a file to put your migration test in. Suggestions: test/unit/import_legacy_devices_migration_test.rb or spec/migrations/import_legacy_devices_migration_spec.rb NOTE: you probably need to explicitly load the migration file as rails will probably not load it for you. Something like this should do: require File.join(Rails.root, 'db', 'migrate', '20101110154036_import_legacy_devices')
  3. Migrations are (like everything in ruby), just a class. Test the up and down methods. If your logic is complex, I suggest refactoring out bits of logic to smaller methods that will be easier to test.
  4. Before calling up, set up some some data as it would be before your migration, and assert that it's state is what you expect afterward.

I hope this helps.

UPDATE: Since posting this, I posted on my blog an example migration test.

UPDATE: Here's an idea for testing migrations even after they've been run in development.

EDIT: I've updated my proof-of-concept to a full spec file using the contrived example from my blog post.

# spec/migrations/add_email_at_utc_hour_to_users_spec.rb
require 'spec_helper'

migration_file_name = Dir[Rails.root.join('db/migrate/*_add_email_at_utc_hour_to_users.rb')].first
require migration_file_name


describe AddEmailAtUtcHourToUsers do

  # This is clearly not very safe or pretty code, and there may be a
  # rails api that handles this. I am just going for a proof of concept here.
  def migration_has_been_run?(version)
    table_name = ActiveRecord::Migrator.schema_migrations_table_name
    query = "SELECT version FROM %s WHERE version = '%s'" % [table_name, version]
    ActiveRecord::Base.connection.execute(query).any?
  end

  let(:migration) { AddEmailAtUtcHourToUsers.new }


  before do
    # You could hard-code the migration number, or find it from the filename...
    if migration_has_been_run?('20120425063641')
      # If this migration has already been in our current database, run down first
      migration.down
    end
  end


  describe '#up' do
    before { migration.up; User.reset_column_information }

    it 'adds the email_at_utc_hour column' do
      User.columns_hash.should have_key('email_at_utc_hour')
    end
  end
end

Solution 2 - Ruby on-Rails

I just create an instance of the class, then call up or down on on it.

For example:

require Rails.root.join(
  'db',
  'migrate',
  '20170516191414_create_identities_ad_accounts_from_ad_account_identity'
)

describe CreateIdentitiesAdAccountsFromAdAccountIdentity do
  subject(:migration) { described_class.new }

  it 'properly creates identities_ad_accounts from ad account identities' do
    create_list :ad_account, 3, identity_id: create(:identity).id

    expect { suppress_output { migration.up } }
      .to change { IdentitiesAdAccount.count }.from(0).to(3)
  end
end

Solution 3 - Ruby on-Rails

> I made a migration that adds a column to a model, and gives it a default value. But I forgot to update all the pre-existing instances of that model to have that default value for the new column.

Based on this statement, you are just trying to test that an "old" model, has the default, correct?

Theoretically you are testing if rails works. I.e., "Does rails set a default value to a newly added column"

Adding a column and setting a default value will be there in the "old" records of your database.

So, you don't need to update the other records to reflect the default setting, then. In theory there is nothing to test, as rails has tested that for you. Lastly, the reason to use defaults is so that you don't have to update the previous instances to use that default, right?

Solution 4 - Ruby on-Rails

> Note: This answer might not actually target the question above. I am writing this for viewers who are here for knowing how to write tests for migrations in Rails.

This is how I did it


Step 1

You need to configure RSpec to use DatabaseCleaner

# spec/support/db_cleaner.rb
RSpec.configure do |config|
  config.around(:each) do |example|
    unless example.metadata[:manual_cleaning]
      DatabaseCleaner.strategy = :transaction
      DatabaseCleaner.cleaning { example.run }
    else
      example.run
    end
  end
end

This will run all your examples in transaction mode which is super fast. And, also you need to run migration tests in truncation mode because you need to make actual database hits.

>Note: You might not need to do as above if you are using truncation as strategy for DatabaseCleaner.


Step 2

Now, you can choose whether you want transaction for that example or group of example using manual_cleaning clause like below.

# spec/migrations/add_shipping_time_settings_spec.rb
require 'spec_helper'
require_relative  '../../db/migrate/20200505100506_add_shipping_time_settings.rb'

describe AddShippingTimeSettings, manual_cleaning: true do
  before do
    DatabaseCleaner.strategy = :truncation
    DatabaseCleaner.clean # Cleaning DB manually before suite
  end

  describe '#up' do
    context 'default values in database' do
      before do
        AddShippingTimeSettings.new.up
      end

      it 'creates required settings with default values' do
        data = Setting.where(code: AddShippingTimeSettings::SHIPPING_TIMES)
        expect(data.count).to eq(AddShippingTimeSettings::SHIPPING_TIMES.count)
        expect(data.map(&:value).uniq).to eq(['7'])
      end
    end
  end

  describe '#down' do
    context 'Clean Up' do
      before do
        AddShippingTimeSettings.new.up
        AddShippingTimeSettings.new.down
      end

      it 'cleans up the mess' do
        data = Setting.where(code: AddShippingTimeSettings::SHIPPING_TIMES)
        expect(data.count).to eq(0)
      end
    end
  end
end

Solution 5 - Ruby on-Rails

I don't know Rails, but I think the approach is the same independently from the tooling I use the following approach:

  • make sure deployed versions of database scripts are apropiatly tagged/labeled in Version Control
  • based on that you need at least three scripts: a script that creates the old version from scratch (1), a script that creates the new version from scratch (2) and a script that creates the new version from the old version (3).
  • create two db instances/schemata. In one run script 2, in the other run script 1 followed by script 3
  • compare the results in the two databases, using sql queries against the data dictionary.

For testing also the effect on actual data, load test data into the databases after executing script 2 and between 1 and 3. Again run sql queries, compare the results

Solution 6 - Ruby on-Rails

This is maybe not the most Railsy answer; Constrain your database.

If you had declared your column not null (null: false in rails migrations) the database wouldn't let you forget to provide a default value.

Relational databases are really good at enforcing constraints. If you get in the habit of adding them you can guarantee the quality of your data.

Imagine if you add a presence validation after some data already exists in production where that validation would fail. First, the validation won't run until the user tries to edit the data and when it does it may not be clear to the user what is causing the error because they may not be concerned with that particular value at this time. Second, your UI may expect that value to exist (after all your validation "guarantees" it) and you'll end up getting a page about an unexpected nil at 2AM. If you constrain the column as not null at the time you add the validation, the database will back-check all existing data and force you to fix it before the migration will complete.

While I use not null in this example the same holds true for a uniqueness validation and really anything else you can express with a constraint.

Solution 7 - Ruby on-Rails

You could consider running isolated portions of your test suite with specific settings against copies of your production data (with e.g. something like yaml_db).

It's a bit meta, and if you know what the potential problems are with your new migrations you'd likely be better off just enhancing them to cover your specific needs, but it's possible.

Solution 8 - Ruby on-Rails

describe 'some_migration' do
  it 'does certain things' do
    context = ActiveRecord::Base.connection.migration_context
    # The version right before some_migration
    version = 20201207234341
    # Rollback to right before some_migration
    context.down(version)

    set_up_some_data

    context.migrate
    # Or if you prefer:
    # context.forward(1)

    expect(certain_things).to be(true)
  end
end

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
QuestionXZVASFDView Question on Stackoverflow
Solution 1 - Ruby on-RailsAmiel MartinView Answer on Stackoverflow
Solution 2 - Ruby on-RailsianksView Answer on Stackoverflow
Solution 3 - Ruby on-RailspjammerView Answer on Stackoverflow
Solution 4 - Ruby on-RailsillusionistView Answer on Stackoverflow
Solution 5 - Ruby on-RailsJens SchauderView Answer on Stackoverflow
Solution 6 - Ruby on-RailsrjspotterView Answer on Stackoverflow
Solution 7 - Ruby on-RailsChris TonkinsonView Answer on Stackoverflow
Solution 8 - Ruby on-RailsJacquenView Answer on Stackoverflow