Article summary
Migrations are one of the most useful additions Rails provides. Knowing that you can rely on them working is good too. Recently we’ve released a plugin, migration_test_helper, which makes it easier to test the migrations you create for a Rails app individually and as a whole.
1 2 |
./script/plugin install svn://rubyforge.org/var/svn/migrationtest/tags/migration_test_helper |
Full Migration Test
Let’s start by creating a migration test that proves we can migrate from an empty schema to the latest version.
1 2 3 4 |
./script/generate full_migration_test create test/migration create test/migration/full_migration_test.rb |
This will create test/migration/full_migration_test.rb. migration_test_helper stores migration tests in their own directory (next to unit, functional and integration) and provides a rake task to run them:
1 2 3 4 5 6 7 8 |
rake test:migrations ... implement me. 1 tests, 1 assertions, 1 failures, 0 errors rake aborted! |
After filling in a few helper methods, this test will:
- drop all tables in the test database
- run your migrations from version 0 to the latest version
- assert the entire schema is as expected
- assert any default data was loaded
The migrate helper has the same behavior as running rake db:migrate against your test database.
Let’s update our new migration test to verify our schema is built (say our app has a users, groups and groups_users table). There are two methods stubbed out in the full_migration_test for us to implement: see_full_schema and see_default_data.
see_full_schema is where we should put our assertions about what the schema looks like:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
def see_full_schema assert_schema do |s| s.table :users do |t| t.column :id, :integer t.column :login, :string t.column :email, :string end s.table :groups do |t| t.column :id, :integer t.column :name, :string end s.table :groups_users do |t| t.column :group_id, :integer t.column :user_id, :integer end end end |
The assert_schema helper will fail if any table isn’t specified or found, or if any table’s column isn’t specified or found (with the matching type). This can be handy for catching mistakes and typos when creating migrations which modify the schema. You can test-drive your schema changes by, first, editing an assert_schema call in a test like the one above, then, creating a migration to accomplish that change.
This style of migration test can also be useful for verifying any default data migrations are working properly. Say your application adds an ‘admin’ user in the ‘Super User’ group through a migration to ensure a new installation can be logged into by its owner. If we fill in the see_default_data method with the following, we can prove that this happens:
1 2 3 4 5 6 7 8 |
def see_default_data the_admin = User.find_by_login('admin') assert_not_nil the_admin, "default admin user not added" super_group = Group.find_by_name('Super User') assert_not_nil super_group, "default Super User group not added" assert the_admin.groups.include?(super_group), "admin user doesn't belong to the Super User group" end |
Individual Migration Tests
Migrations which modify data that isn’t loaded by another migration can be more difficult to test. Say our users table has some entries with bad email addresses in it, and we want to make a migration to detect and blank out those invalid email addresses. First, let’s create the new migration, but not implement it:
1 2 3 4 |
./script/generate migration remove_junk_email_addresses exists db/migrate create db/migrate/042_remove_junk_email_addresses.rb |
Now let’s make an individual test for this migration. If given a migration number, instead of a name, the migration_test generator will create a test targeted at a particular migration.
1 2 3 4 |
./script/generate migration_test 42 exists test/migration create test/migration/042_remove_junk_email_addresses_test.rb |
The generated test method will look something like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
def test_migration_should_remove_junk_email_addresses drop_all_tables migrate :version => 41 flunk "TODO: setup test data" migrate :version => 42 flunk "TODO: examine results" migrate :version => 41 flunk "TODO: examine results" end |
Since there aren’t default users added by previous migrations that have examples of ‘good’ and ‘bad’ email addresses to test against, we’ll have to add some for testing right before our migration runs. Then we can see that they’re updated once we’ve run our new migration.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
def test_migration_should_remove_junk_email_addresses drop_all_tables migrate :version => 41 User.reset_column_information good_user = User.create!(:email => '[email protected]') bad_user = User.create!(:email => 'what? email?') migrate :version => 42 good_user.reload assert_equal '[email protected]', good_user.email, "valid email should have been left alone" bad_user.reload assert_equal '', bad_user.email, "bad user should have no email set" migrate :version => 41 flunk "TODO: examine results" end |
This test will migrate us all the way up to the migration right before ours. Here we stop to add some data which we can use to exercise what our migration 042 is supposed to do. Then we finish by migrating up to our version and see it had the desired effect on the users email addresses. The User.reset_column_information call ensures that when we talk about the User model in our test it doesn’t have any stale idea of what its columns are.
The only thing left is that migrate to version 41. What’s this for? You should test your down migrations, even if it does nothing. The down part of a migration is something you rarely need, but when you need it, you REALLY need it to behave correctly, eg. during a rollback.
In our case, let’s say we don’t care about restoring the old broken email addresses, and not do anything. We’re not proving much, but, we are proving that we didn’t write something stupid in there that’ll stop a further (more critical) down migration during a rollback:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
def test_migration_should_remove_junk_email_addresses ... migrate :version => 41 good_user.reload assert_equal '[email protected]', good_user.email, "valid email should have been left alone" bad_user.reload assert_equal '', bad_user.email, "bad user should have no email set" end |
Why Bother?
Testing your migrations can save you time and help you sleep. If you catch a problem with a migration while test-driving its creation, your development database won’t have to suffer being dropped down to version 0 then back up again and again while you try to figure out what’s wrong.
It’s better to know sooner than later when your migrations stop working. Maybe you’ll realize that it doesn’t matter since all production machines and developers have already migrated past that point. If that’s not the case, however, you have a problem that needs to be fixed before you deploy.
Some of the difficulties with migrations that can become apparent when trying to responsibly test them can be lessened by trying to follow some guidelines when possible:
Try to avoid using your model classes in migrations
Even though it is often much quicker to use your models and their associations to implement a data migration, it can be more painful down the road if that model’s table, columns or associations change or get removed. If you can stick with gross SQL using helpers on ActiveRecord::Base.connection like select_all, insert, select_value, etc. then your migration will be more likely to not break later on as your application evolves.
Try to get rid of old migrations
If you get to a point where every production machine and development workstation is beyond, say, schema version 33, try to collapse migrations 1-33 into one migration. This can be done by running your migrations from 0 => 33, and making your 033 migration create the schema and add any default data that exists at that point. To save time you can try taking a SQL dump of the entire database at that point, and simply have your “genesis” 033 migration load that.
This trick is much easier to accomplish if you have tests in place to ensure that your ‘compression’ of un-needed migrations didn’t miss anything.
Conclusion
Glenn Vanderberg gave a talk at RailsConf 2006 (which I missed) that seemed to discuss some of these problems. In it, he emphasized the importance of data migrations and down migrations, which I agree is worth pointing out. When you don’t need migrations anymore, you should find a way to get rid of them, and when you DO need them to work, you should make sure they do.
Do you plan to upgrade this to work with the newer create_table syntax? t.integer, etc ? Seems to still require t.column :integer syntax.
Hey, Micah,
Very cool plugin! I’m just getting into Rails development, and was happy to find some help for testing migrations. One question: do you have plans to support the new table/column definition syntax so that I could write
assert_table :cats_tails do |t|
t.integer :id
t.string :name
end
??
Thanks!
Liam
If anyone could use it, I did end up writing some code to translate from the t.column syntax to the t.integer, etc., syntax:
http://liamgraham.wordpress.com/2008/10/22/some-convenience-code-for-using-micah-alles-rails-migration-testing-plugin/
Cheers,
Liam
In regards to avoiding the use of model classes in your migrations you can get around this issue by creating the required model classes inside of your migration’s class declaration. This creates a snapshot in time of your model at the time of the migration and avoids the issues of your model, associations, etc changing in the future.
This was a great article, I look forward to putting the plugin to use!
Zach,
That solution can be used in some cases, but if you can still be burned by STI models and those that use polymorphic associations since your model’s fully qualified class name is different. I’ve found myself bringing in a few models sometimes which would eventually lead me to bring in most of them (for complicated data migrations) and, eventually I would run into an STI model or something and want to spit on my screen.
Good tip though, since it can still save you time if you’re careful.
I am trying to use this plugin but some of my test fail in situations where i have filters , how can i assure that all my filters are working appropriately as required and what are the ways to test migrations , are we looking forward to check that data exists that has been added in earlier migrations or creating new rows every time we migrate to new versions.
Hey i finally have figured out how it all works
for example u have a after filter like after create where it creates new rows for a table but the table is not existing as the migrated version is not to that level so what i suggest is u can use migrate :version => 10 for 10 is the version which has schema for that new table to add row .. this solves the after filter problems ….
i figured it out for harshly working on it for nearly five days .