Testing Migrations¶
runic ships two distinct testing mechanisms:
``runic test`` CLI command — round-trip tests a single revision against a real graph.
``runic.migrate.testing`` pytest fixtures — utilities for writing unit and integration tests for migration scripts in your own test suite.
Round-trip testing with runic test¶
runic test <rev> runs a three-phase idempotency check on an ephemeral
graph:
Phase A — upgrade to the target revision.
Phase B — downgrade to
base.Phase C — upgrade again (idempotency check).
At each phase, runic reports the count of nodes, indexes, and constraints:
$ runic test 3f9a12c1
runic test 3f9a12c1ab4e
─────────────────────────────────────────────
Phase A (upgrade): ✓ nodes=0 indices=1 constraints=1
Phase B (downgrade): ✓ nodes=0 indices=0 constraints=0
Phase C (idempotency):✓ nodes=0 indices=1 constraints=1
─────────────────────────────────────────────
PASSED
The command creates a temporary graph named
<source_graph>__test_<rev>_<token>, runs all three phases, then deletes
the graph — regardless of pass or fail. Your production graph is never
touched.
Running against an embedded server¶
You can point runic test at a separate URL to avoid needing a production
connection:
$ runic test 3f9a12c1 --url falkor://localhost:6379 --graph test_graph
Or use falkordblite for an embedded server (no Docker required). Configure
env.py to use the embedded adapter:
# runic/env.py (falkordblite variant)
from pathlib import Path
from redislite import FalkorDB
from runic.migrate import context
from runic.migrate.adapters.falkordb import FalkorDBAdapter
db = FalkorDB(protocol=2)
graph = db.select_graph("test")
adapter = FalkorDBAdapter(db, graph)
context.configure(adapter, script_location=Path("runic"))
Then run runic test 3f9a12c1 without any --url flag.
pytest fixtures¶
runic.migrate.testing exports two pytest fixtures for use in your own test suite.
Add to your conftest.py:
from runic.migrate.testing import falkordb_graph, runic_context # noqa: F401
Or import directly in test files:
import pytest
from runic.migrate.testing import falkordb_graph, runic_context
The fixtures use falkordblite (installed as redislite) for an
embedded FalkorDB server that starts and stops with the test process.
Note
Both fixtures skip the test automatically if falkordblite is not
installed. Install it with uv add --dev falkordblite.
falkordb_graph fixture¶
Yields a (db, graph) tuple backed by an ephemeral embedded graph. The
graph is deleted after the test.
def test_index_creation(falkordb_graph) -> None:
db, graph = falkordb_graph
graph.query("CREATE INDEX FOR (n:User) ON (n.id)")
result = graph.ro_query("CALL db.indexes() YIELD label")
assert result.result_set
runic_context fixture¶
Yields a fully configured Runic instance backed by
an ephemeral embedded graph and a temporary versions/ directory. Use this
to test upgrade/downgrade logic end-to-end.
from pathlib import Path
from runic.migrate.context import Runic
from runic.migrate.testing import runic_context
def test_full_migration(runic_context, tmp_path) -> None:
ctx = runic_context
versions = ctx.script_location / "versions"
# Write a migration script programmatically
(versions / "0001_test_index.py").write_text("""
from datetime import UTC, datetime
revision = "0001"
down_revision = None
branch_labels = []
depends_on = []
irreversible = False
snapshot = False
message = "test"
create_date = datetime.now(UTC)
def upgrade(op) -> None:
op.create_range_index("Person", "email")
def downgrade(op) -> None:
op.drop_range_index("Person", "email")
""")
# Create a fresh Runic instance to pick up the new revision file
ctx2 = Runic(ctx.adapter, ctx.script_location)
ctx2.upgrade("head")
assert ctx2.current() == "0001"
ctx2.downgrade("base")
assert ctx2.current() is None
Writing testable migration scripts¶
Keep migration scripts testable by avoiding side effects outside upgrade
and downgrade:
Do not query the database at module import time.
Keep all state in
op.*calls or local variables.For seed data, prefer deterministic
op.seed(...)calls overop.run_cypherwith hard-coded values.
Use the runic test command as a first-pass sanity check before committing,
and write focused pytest tests for scripts that involve complex data
transformations.
Integration test markers¶
If you add the integration pytest marker to tests that require
falkordblite, you can skip them in environments without it:
@pytest.mark.integration
def test_migration_round_trip(runic_context) -> None:
...
# Run only unit tests (skip integration)
pytest -m "not integration"
# Run all including integration
pytest