Skip to content

Getting Started

This guide walks you through setting up pytest-mrt from scratch, even if you've never written a pytest plugin before.


What you need

  • Python 3.10 or higher
  • An existing project that uses Alembic for database migrations
  • A test database (separate from your production DB — see below)

Step 1 — Install

pip install pytest-mrt

That's it. Because pytest-mrt is a pytest plugin, it automatically activates when you run pytest. You don't need to import it anywhere.


Step 2 — Create a test database

pytest-mrt will run real migrations against a real database. Never use your production database.

createdb myapp_test

Your database URL will be:

postgresql://localhost/myapp_test

No setup needed. Just use a file path:

sqlite:///test.db

SQLite is great for local development and CI. For production-like testing, use PostgreSQL.


Step 3 — Create conftest.py

conftest.py is a special pytest file where you configure plugins. Create it in your project root (or test directory):

# conftest.py
import os
from pytest_mrt import MRTConfig


def pytest_configure(config):
    config._mrt_config = MRTConfig(
        alembic_ini="alembic.ini",            # path to your alembic.ini file
        db_url="postgresql://localhost/myapp_test",  # your test database URL
    )

Use environment variables in CI

def pytest_configure(config):
    config._mrt_config = MRTConfig(
        alembic_ini="alembic.ini",
        db_url=os.environ.get("TEST_DATABASE_URL", "sqlite:///test.db"),
    )
This way your local machine uses SQLite and CI uses PostgreSQL — no config changes needed.


Step 4 — Write your first test

Create a test file:

# tests/test_migrations.py


def test_all_migrations_are_reversible(mrt):
    """
    Checks every migration in your project.
    Seeds real data, runs upgrade, runs downgrade,
    and verifies nothing was lost.
    """
    mrt.assert_all_reversible()

What is mrt?

mrt is a pytest fixture — a special argument that pytest automatically injects into your test function. You don't need to import or create it. Just add mrt as a parameter and it works.


Step 5 — Run it

pytest tests/test_migrations.py -s -v

The -s flag is important — it lets the output render properly.

If all migrations are safe:

  ──────────── MRT — Migration Rollback Test ────────────

  ✓  001  reversible
  ✓  002  reversible
  ✓  003  reversible

  ╭──────────────────────────────────────────╮
  │  All 3 migration(s) are safely reversible│
  ╰──────────────────────────────────────────╯

PASSED

If a migration is unsafe:

  ✓  001  reversible
  ✓  002  reversible
  ✗  003  data loss detected
     └─ Table 'users': 3/3 rows lost after rollback

  ╭──────────────────────────────────────────────────╮
  │  1 migration(s) will cause data loss on rollback │
  ╰──────────────────────────────────────────────────╯

FAILED


Running all migrations on every PR can be slow. In CI, you usually only need to check the migration added in the current PR.

# tests/test_migrations.py

def test_this_migration(mrt):
    """
    Replace 'abc1234' with the revision ID of your new migration.
    Find it at the top of your migration file:
      revision = 'abc1234'
    """
    result = mrt.check_revision("abc1234")
    assert result.passed, result.failure_summary()

If the migration is unsafe, result.failure_summary() prints exactly what went wrong:

  - Table 'users': 3/3 rows lost after rollback


Static analysis — catch problems without running anything

Before touching your database, scan migration files for known dangerous patterns:

mrt check migrations/versions/

This is fast (no DB needed) and catches things like:

  • op.drop_column() in upgrade — data loss even if downgrade re-adds the column
  • def downgrade(): pass — rollback silently does nothing
  • ALTER TYPE ADD VALUE — can't be rolled back in PostgreSQL
  • ...and 40+ more patterns

Exit codes:

Code Meaning
0 Clean — no issues
1 Warnings found (without --strict)
2 Errors found, or warnings with --strict

Add --strict to also exit 2 on warnings:

mrt check migrations/versions/ --strict

Built-in default tests

When MRTConfig is configured, pytest-mrt automatically injects 6 safety tests into your suite — no extra test files needed:

Test What it checks
test_mrt_single_head Migration history has exactly one head
test_mrt_upgrade alembic upgrade head completes without error
test_mrt_downgrade_base Full up → down → up cycle succeeds
test_mrt_up_down_consistency Every migration is safely reversible
test_mrt_static_no_errors Zero static analysis errors
test_mrt_schema_matches_models DB schema matches ORM models (requires target_metadata)

To opt out of a specific test:

MRTConfig(
    skip_default_tests={"test_mrt_schema_matches_models"},
)

Suppressing known warnings

Use # noqa: MRTxxx to suppress a specific warning on a line (ruff/flake8 convention):

def upgrade():
    op.drop_column("users", "legacy_col")  # noqa: MRT103

Use bare # noqa to suppress all MRT warnings on a line.


Adding to GitHub Actions

# .github/workflows/ci.yml
name: CI

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: testdb
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
        ports:
          - 5432:5432

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install dependencies
        run: pip install pytest-mrt psycopg2-binary alembic

      - name: Static migration check (fast, no DB)
        run: mrt check migrations/versions/

      - name: Dynamic migration check (with real DB)
        env:
          TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/testdb
        run: pytest tests/test_migrations.py -v -s

Configuration options

All options for MRTConfig:

from pytest_mrt import MRTConfig

MRTConfig(
    # Alembic
    alembic_ini="alembic.ini",          # Path to alembic.ini (default: "alembic.ini")
    db_url="postgresql://...",          # Test DB URL. Never use production.
    seed_rows=3,                        # Rows inserted per table before each rollback check

    # Django (use instead of alembic_ini)
    django_settings="myapp.settings",  # Django settings module
    django_apps=["users", "orders"],   # Limit to specific app labels (None = all)
    django_project_dir="/path/to/app", # Project root if conftest is elsewhere

    # Rollback floor — skip old migrations already tested historically
    minimum_downgrade_revision="abc123def456",  # Alembic: revision ID
    # minimum_downgrade_revision="myapp.0050_baseline",  # Django: app_label.migration_name

    # Skip specific migrations
    skip={
        "abc123": "Intentional data migration. Reviewed 2024-01-15. See ADR-007.",
    },

    # Override severity for a specific pattern
    severity_overrides={"INDEX without CONCURRENTLY": "error"},

    # Custom seed data per table
    custom_seeds={"users": lambda: [{"id": 1, "name": "Alice"}]},

    # Schema drift check
    target_metadata="myapp.models:Base",

    # Timeout per migration check in seconds
    migration_timeout=60,
)

Common errors

MRTConfig not set

AttributeError: 'Config' object has no attribute '_mrt_config'

You haven't created conftest.py or the pytest_configure function is missing. See Step 3.


could not connect to server

sqlalchemy.exc.OperationalError: could not connect to server: Connection refused

Your test database isn't running, or the URL is wrong. Check:

# PostgreSQL
psql postgresql://localhost/myapp_test

# Should connect without errors


FAILED — Table not found

The migration file references a table that doesn't exist yet. Make sure your migrations run in the correct order and alembic.ini points to the right versions directory.


Target database is not up to date

Run alembic upgrade head on your test database first, then run the tests again. Or let pytest-mrt handle it — assert_all_reversible() starts from base automatically.