Writing Unit Tests for Django Migrations

Calvin Spealman

February 2, 2016

Editor's note: This post was originally published in February 2016 and was updated in August 2017 to incorporate improvements suggested by our readers. It has also been tested for compatibility as of the Django 1.11 release.

Testing in a Django project ensures the latest version of a project is as bug-free as possible. But when deploying, you’re dealing with multiple versions of the project through the migrations.

The test runner is extremely helpful in its creation and cleanup of a test database for our test suite. In this temporary test database, all of the project's migrations are run before our tests. This means our tests are running the latest version of the schema and are unable to verify the behavior of those very migrations because the tests cannot set up data before the migrations run
or assert conditions about them.

We can teach our tests to run against those migrations with just a bit of work. This is especially
helpful for migrations that are going to include significant alterations to existing data.

The Django test runner begins each run by creating a new database and running all migrations in
it. This ensures that every test is running against the current schema the project expects, but
we'll need to work around this setup in order to test those migrations. To accomplish this, we'll
need to have the test runner step back in the migration chain just for the tests against them.

Ultimately, we're going to try to write tests against migrations that look like this:

Before explaining how to make this work, we'll break down how this test is actually written.

We're inheriting from a TestCase helper that will be written to make testing migrations possible
named TestMigrations and defining for this class two attributes that configure the migrations
before and after that we want to test. migrate_from is the last migration we expect to be
run on machines we want to deploy to and migrate_to is the latest new migration we're testing
before deploying.

Because our test is about a migration, data modifying migrations in particular, we want to do
some setup before the migration in question (0010_migration_being_tested) is run. An extra
setup method is defined to do that kind of data setup after0009_previous_migration has run
but before 0010_migration_being_tested.

Once our test runs this setup, we expect the final 0010_migration_being_tested migration to
be run. At that time, one or more test_*() methods we define can do the sort of assertions
tests would normally do. In this case, we're making sure data was converted to the new schema
correctly.

We'll be extending the TestCase class. In order to control migration running, we'll
use MigrationExecutor, which needs the database connection to operate on. Migrations are
tied pretty intrinsically to Django applications, so we'll be using django.apps.apps and, in
particular, get_containing_app_config() to identify the current app our tests are running in.

After insisting the test case class had defined migrate_to and migrate_from migrations,
we use the internal MigrationExecutor utility to get a state of the applications as of the
older of the two migrations.

We'll use old_apps in our setUpBeforeMigration() to work with old versions of the models
from this app. First, we'll run our migrations backwards to return to this original migration
and then call the setUpBeforeMigration() method.

# Reverse to the original migrationexecutor.migrate(self.migrate_from)self.setUpBeforeMigration(old_apps)

Now that we've set up the old state, we simply run the migrations forward again. If the migrations
are correct, they should update any test data we created. Of course, we're validating
that in our actual tests.

# Run the migration to testexecutor=MigrationExecutor(connection)executor.loader.build_graph()# reload.executor.migrate(self.migrate_to)

And finally, we store a current version of the app configuration that our tests can access and
define a no-op setUpBeforeMigration()

fromdjango.appsimportappsfromdjango.testimportTestCasefromdjango.db.migrations.executorimportMigrationExecutorfromdjango.dbimportconnectionclassTestMigrations(TestCase):@propertydefapp(self):returnapps.get_containing_app_config(type(self).__module__).namemigrate_from=Nonemigrate_to=NonedefsetUp(self):assertself.migrate_fromandself.migrate_to, \
"TestCase '{}' must define migrate_from and migrate_to properties".format(type(self).__name__)self.migrate_from=[(self.app,self.migrate_from)]self.migrate_to=[(self.app,self.migrate_to)]executor=MigrationExecutor(connection)old_apps=executor.loader.project_state(self.migrate_from).apps# Reverse to the original migrationexecutor.migrate(self.migrate_from)self.setUpBeforeMigration(old_apps)# Run the migration to testexecutor=MigrationExecutor(connection)executor.loader.build_graph()# reload.executor.migrate(self.migrate_to)self.apps=executor.loader.project_state(self.migrate_to).appsdefsetUpBeforeMigration(self,apps):passclassTagsTestCase(TestMigrations):migrate_from='0009_previous_migration'migrate_to='0010_migration_being_tested'defsetUpBeforeMigration(self,apps):BlogPost=apps.get_model('blog','Post')self.post_id=BlogPost.objects.create(title="A test post with tags",body="",tags="tag1 tag2",).iddeftest_tags_migrated(self):BlogPost=self.apps.get_model('blog','Post')post=BlogPost.objects.get(id=self.post_id)self.assertEqual(post.tags.count(),2)self.assertEqual(post.tags.all()[0].name,"tag1")self.assertEqual(post.tags.all()[1].name,"tag2")

Migrations are an essential part of any Django project today, as are thorough and reliable tests. Hopefully, you can combine these two essentials now. These techniques may be particularly helpful when maintaining Django apps and libraries distributed to many users. A single project might only have one primary database in production, but distributed libraries have to have predictable, safe migrations your users can depend on. Now you can make sure of that.

Not every migration needs thorough tests! For simple non-data migrations like adding a new null column or adding a new table, you’d only be redoing the work of testing the migration tooling in Django itself, because those migrations are simple enough not to have any special impact on your data. However, this method of testing migrations can be valuable when you have data migrations or schema migrations that might be sensitive to data, such as changing constraints, renaming tables and columns, or building new indexes.