Library / SDK
wemake-services/django-test-migrations avatar
wemake-services/django-test-migrations

django-test-migrations lets a Django test assert on a migration's before and after state

Test django schema and data migrations, including migrations' order and best practices.

576 stars35 forksPythonMIT

At a glance

What is it?
django-test-migrations is an MIT licensed package that lets a Django test apply one migration on top of the state produced by the migrations before it, then assert on the model, fields and data at both ends, and that also checks migration ordering and rollback paths. It suits a team with data migrations that need to be proven not to corrupt existing rows, and it makes the test database the place where that proof lives.
Who is it for?
django-test-migrations fits a team with data migrations whose correctness matters, because it gives a test the same before state a production migration would see, which is the only way to assert that a backfill did what it was supposed to.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 1 day ago.
What is it written in?
Mainly Python, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The gap the package fills is data, not schema

The package description is test django schema and data migrations, including migrations order and best practices, and the documentation is refreshingly honest about how rare this is.

Testing migrations is not a frequent thing in django land. But, sometimes it is totally required. The cases it lists are specific: when we do complex schema or data changes and want to be sure that existing data won't be corrupted, when we want to be sure that all migrations can be safely rolled back, and as a final touch to be sure that migrations are in the correct order and have correct dependencies.

That list describes the pain precisely. A schema migration that adds a nullable column is hard to get wrong. A data migration that rewrites a column's contents, or converts a value across a type change, is exactly the kind of code that works in a fresh database and destroys a production table of existing rows. The fresh database is the problem: when a test suite runs, the test database is built by running all migrations from scratch, so every migration sees an empty table and the data path is never exercised.

The package solves that by giving a test control over which migration is the one under test, and what the state looks like immediately before it runs.

The Migrator gives you the state before and after one migration

The Migrator class is the core, and it has three methods.

`.apply_initial_migration()` takes app and migration names to generate a state before the actual migration happens, creating the before state by applying all migrations up to and including the ones passed as an argument. `.apply_tested_migration()` takes app and migration names to perform the actual migration. `.reset()` cleans everything up after the testing is done.

The documented example makes the shape clear.

python
from django_test_migrations.migrator import Migrator

migrator = Migrator(database='default')

old_state = migrator.apply_initial_migration(('main_app', '0001_initial'))
SomeItem = old_state.apps.get_model('main_app', 'SomeItem')

SomeItem.objects.create(string_field='a')
assert len(SomeItem._meta.get_fields()) == 2  # id + string_field

The important detail is where the model comes from. It is not imported from your app, it is fetched from the returned state with `old_state.apps.get_model`, so the model you assert against is the model as it was at that point in history, with exactly the fields that existed then.

After the migration is applied, the same pattern gives you the after state.

python
new_state = migrator.apply_tested_migration(
    ('main_app', '0002_someitem_is_clean'),
)
SomeItem = new_state.apps.get_model('main_app', 'SomeItem')

assert SomeItem.objects.filter(is_clean=True).count() == 0
assert len(SomeItem._meta.get_fields()) == 3  # id + string_field + is_clean

So the test creates a row in the old shape, applies the migration, and then checks both the shape of the model and the contents of the table. That is the assertion a data migration deserves, and Django's own test runner cannot write it.

Rollback is the same call with the names swapped

Backward migrations are tested with the same machinery, and the documentation says nothing really changes except the migration names you pass and your logic.

python
migrator = Migrator()

old_state = migrator.apply_initial_migration(
    ('main_app', '0002_someitem_is_clean'),
)
SomeItem = old_state.apps.get_model('main_app', 'SomeItem')

new_state = migrator.apply_tested_migration(('main_app', '0001_initial'))

The application order is reversed: you bring the database up to 0002, create data to illustrate the case, and then apply 0001_initial as the migration under test, which drops the is_clean field. The assertion then checks what survived the rollback.

This is the part of the package that has no good alternative. A rollback that silently drops a column you still need is the failure mode that hurts most in production, because the forward path worked and the rollback was never exercised. The documentation is blunt that testing migrations is not a frequent thing, which is precisely why the rollback path tends to be the untested one.

The same Migrator API serves both directions, so a team that has learned the forward case has already learned the backward one.

Migration order is a separate assertion, not a side effect

Ordering is checked by a different module, plan.py, and the API is deliberately declarative: you assert the expected sequence.

python
from django_test_migrations.plan import all_migrations, nodes_to_tuples

main_migrations = all_migrations('default', ['main_app', 'other_app'])
assert nodes_to_tuples(main_migrations) == [
    ('main_app', '0001_initial'),
    ('main_app', '0002_someitem_is_clean'),
    ('other_app', '0001_initial'),
    ('main_app', '0003_update_is_clean'),
    ('other_app', '0002_auto_20191120_2230'),
]

The assertion is a literal list of app and migration name pairs, and the documentation explains why that is the point: this way you can be sure that migrations and apps that depend on each other will be executed in the correct order.

A literal list is a strict assertion. Any new migration added to either app fails the test until the expected list is updated, which is the behaviour you want if you believe the order is a decision rather than an accident. The cost is that adding a migration becomes a two-step change, and a team that adds migrations frequently will feel that.

The list also encodes cross-app dependencies, since the example interleaves main_app and other_app, and a wrong dependencies list produces an order that differs from the expected tuple.

factory_boy and pytest fixtures, and one sharp edge

Two integrations make the package usable in a normal Django test suite rather than as a separate harness.

The factory_boy integration lets you replace a `.build()` or `.create()` call with a method of `factory`, passing the model name and the factory class as arguments.

python
import factory

old_state = migrator.apply_initial_migration(
    ('main_app', '0002_someitem_is_clean'),
)
SomeItem = old_state.apps.get_model('main_app', 'SomeItem')

factory.create(SomeItem, FACTORY_CLASS=SomeItemFactory)

The pytest plugin ships with the package and provides two fixtures: `migrator_factory`, which gives you the opportunity to create Migrator classes for any database, and a `migrator` instance for the default case. The plugin is registered through the pytest11 entry point, so it loads automatically when the package is installed.

Then there is the sharp edge, and the documentation states it in a note rather than burying it. The Django post_migrate signal's receiver list is cleared at the start of tests and restored afterwards. If you need to test your own post_migrate signals then attach or remove them during a test.

That is a deliberate trade. Clearing the receivers makes the test environment deterministic, since a third party app's post_migrate handler cannot quietly create rows or change state underneath your migration assertion. The cost is that your own signal handlers are switched off by default, and a test that depends on them will pass for the wrong reason unless you reattach them inside the test.

Packaging, supported versions and the multi database matrix

The package metadata is small and modern. It is built with poetry-core, version 1.7.0, licensed MIT, and requires Python 3.11 or newer. The runtime dependencies list is empty, which is notable: django-test-migrations does not drag Django in as a dependency, and the dev group carries django, django-stubs, mypy, pytest with the plugins, ruff and wemake-python-styleguide.

The supported Django versions are 5.2, 6.0 and 6.1, and the documentation says other versions most likely will work too, but they are not officially supported.

The repository layout shows the test matrix in the compose file. There are four database services: postgres:14 on port 5432, postgres:17 on port 5433, mysql:9.7 on 3306, and mariadb:11.4 on 3307. The MySQL and MariaDB entries carry a note explaining that the container entrypoint gives the user access only to the named database, so MYSQL_DATABASE and MARIADB_DATABASE are set to Django's default test database name to avoid overriding the entrypoint or command, and the MySQL entry also sets MYSQL_ROOT_HOST because MySQL restricts root to localhost by default.

Running the same migration tests against four engines and two major versions of Postgres is a strong statement about what a migration is allowed to depend on. If your data migration works on SQLite and fails on MySQL, you want to know that in CI rather than in production.

The Makefile ties it together with a test target that runs lint, unit and package in sequence, and the unit target runs pytest twice, once with --nomigrations to make sure that mode still works and once normally.

Where it is the wrong tool

There is a real case against, and it is about how much of your test suite this should cover.

A migration test is not free. It runs real migrations against a real database, and it does so in a specific order, which means the test is not parallel-safe in the way a plain unit test is, and it is slow enough that you do not want one per row of a data backfill. The package is built for a targeted assertion: this migration, this state, this result. Applied to every migration in a large project, the suite cost goes up sharply.

The second limit is the ordering assertion. A literal expected list of migrations is a strong statement, and it is a statement that a team has to maintain. In a project where migrations are added several times a week, the friction of updating the expected list each time is real, and the value of the assertion depends on whether the order is something the team actually believes should be pinned.

The alternative to write yourself is not another package, it is Django's own machinery. The MigrationExecutor can be driven directly, and the django_test_app/ directory in this repository is a test application for the package itself. Someone who finds the Migrator API awkward can drop two levels down to the executor and lose the ordering and factory_boy helpers, keeping the ability to assert on state.

What the package adds over that is the ergonomics and the discipline: a state object you can query, a fixture that gives it to you, and a documented way to think about migrations as something with a before and an after.

Editorial conclusion

django-test-migrations fits a team with data migrations whose correctness matters, because it gives a test the same before state a production migration would see, which is the only way to assert that a backfill did what it was supposed to. It does not fit a project with a handful of schema-only migrations, where Django's own migration check and a smoke test are enough, and it does not fit a team that has not accepted pytest, since the pytest fixtures are the documented entry point. Before adopting it, confirm your Django version is one of 5.2, 6.0 or 6.1, since the package states other versions most likely work but are not officially supported, and read the note about the post_migrate receiver list being cleared at the start of tests, because that silently changes the behaviour of your own signal handlers.

Frequently asked questions

How do I test a single Django migration with django-test-migrations?

Create a Migrator, call apply_initial_migration with the app and the migration before the one you are testing, create data through the model from the returned state, then call apply_tested_migration with the migration under test and assert on the new state. Call migrator.reset() when you are done.

Can django-test-migrations test rollback migrations?

Yes. The documentation says nothing really changes except the migration names you pass and your logic: apply the later migration as the initial state, create data, then apply the earlier migration as the tested one and assert on what survived.

Which Django versions does django-test-migrations support?

Django 5.2, 6.0 and 6.1 are the supported versions. The documentation says other versions most likely will work too, but they are not officially supported.

What happens to post_migrate signal receivers during these tests?

The Django post_migrate signal's receiver list is cleared at the start of tests and restored afterwards, so if you need to test your own post_migrate signals you should attach or remove them during the test.

Official sources

  1. License: MIT
  2. Project website
  3. README
  4. Releases
  5. wemake-services/django-test-migrations on GitHub
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/wemake-services-django-test-migrations.svg)](https://hysenlabs.com/projects/wemake-services-django-test-migrations)

Was this useful?