Django: Test for pending migrations

Django requires every change to model fields and meta classes to be reflected in database migrations. This applies even to things that don’t typically affect the database, such as Field.choices. When iterating on code, it’s easy to make a model change and forget to update the migrations accordingly. If you don’t have any protection, you might even deploy code that crashes due to out-of-date migrations!
To protect against this, you can run the makemigrations command with a couple of flags:
$ ./manage.py makemigrations --check
The --check flag causes the command to fail (have a non-zero exit code) if any migrations are missing. (Before Django 4.2.9, you also need to add the --dry-run option, to prevent the command from writing migrations to disk.)
Let’s look at an example project that is missing a migration.
The project has an Author model with a name field. The migrations create the name field with a max_length of 100 characters. But the model has since been updated to use a max_length of 200.
Run the above command, and it will fail with a report of the required migration:
$ ./manage.py makemigrations --check
Migrations for 'example':
example/migrations/0002_alter_author_name.py
- Alter field name on author
This command does the job, but it’s also another thing to remember. It’s not likely you’ll run it locally when you need to, and it needs another step in your CI system.
A convenient way to run this command is within a test. You can set up a test in your project’s “core” app to run the command and fail if it detects any missing migrations. Here’s the code:
from io import StringIO
from django.core.management import call_command
from django.test import TestCase
class PendingMigrationsTests(TestCase):
def test_no_pending_migrations(self):
# No migrations pending
# See: https://adamj.eu/tech/2024/06/23/django-test-pending-migrations/
out = StringIO()
try:
call_command(
"makemigrations",
"--check",
stdout=out,
stderr=StringIO(),
)
except SystemExit: # pragma: no cover
raise AssertionError("Pending migrations:\n" + out.getvalue()) from None
It should work to copy this test case as-is into your project.
Here’s how it works:
The test calls
makemigrationsusing Django’scall_command(). Thestdoutandstderrarguments capture the output to avoid tests displaying it when successful.When the command fails, it raises a
SystemExitbecause there are pending migrations. Theexceptclause catches this case and, in turn, raises anAssertionErrorwith the command output. TheAssertionErrorfails the test.(The
raiseusesfrom Noneto hide the unnecessary details of theSystemExitexception.)
When you run this test, you’ll see the command output (colourized if your terminal supports it):
$ ./manage.py test
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
======================================================================
FAIL: test_no_pending_migrations (example.tests.test_migrations.PendingMigrationsTests.test_no_pending_migrations)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/.../test_migrations.py", line 19, in test_no_pending_migrations
raise AssertionError(
AssertionError: Pending migrations:
Migrations for 'example':
example/migrations/0002_alter_author_name.py
- Alter field name on author
----------------------------------------------------------------------
Ran 1 test in 0.129s
FAILED (failures=1)
Destroying test database for alias 'default'...
Add this test to your project and protect yourself against missing migrations.
😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸
One summary email a week, no spam, I pinky promise.
Related posts:
Tags: django