Skip to content

Commit 83e06af

Browse files
authored
test: add unit tests for django_runner and django_verifier adapters (#20)
* docs: add pepy.tech downloads badge and dependents link * fix: error on nonexistent path in mrt check (#16) * fix: clear error message for Django users missing alembic.ini or env.py (#17) * feat: auto-detect Django mode from DJANGO_SETTINGS_MODULE + mrt init Django support (#17) * test: add coverage for nonexistent path error, Django auto-detect, and missing alembic.ini/env.py * test: add coverage for detector patterns, schema diff, and ast_analyzer helpers * fix: remove unused os imports in cli.py, mock Django in auto-detect test * style: ruff format fixes * test: add unit tests for django_runner and django_verifier (0% → 96%/100%) Mock-based unit tests that run without Django installed — covers _sqlalchemy_url_to_django_db, _configure_django all branches, DjangoMigrationRunner methods, and DjangoRollbackVerifier including skip/timeout/exception/recovery paths.
1 parent 18126f4 commit 83e06af

2 files changed

Lines changed: 733 additions & 0 deletions

File tree

tests/test_django_runner_unit.py

Lines changed: 325 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,325 @@
1+
"""
2+
Unit tests for django_runner.py — no real Django or database required.
3+
4+
Patches sys.modules to simulate Django presence/absence and uses
5+
mock.patch on create_engine to avoid real database connections.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import sys
11+
import unittest.mock as mock
12+
13+
import pytest
14+
15+
16+
# ── DjangoMigration ──────────────────────────────────────────────────────
17+
18+
def test_migration_revision():
19+
from pytest_mrt.adapters.django_runner import DjangoMigration
20+
21+
m = DjangoMigration(app_label="myapp", name="0001_initial")
22+
assert m.revision == "myapp/0001_initial"
23+
24+
25+
def test_migration_filename():
26+
from pytest_mrt.adapters.django_runner import DjangoMigration
27+
28+
m = DjangoMigration(app_label="myapp", name="0001_initial")
29+
assert m.filename == "0001_initial.py"
30+
31+
32+
# ── _sqlalchemy_url_to_django_db ─────────────────────────────────────────
33+
34+
def test_sqlite_file_url():
35+
from pytest_mrt.adapters.django_runner import _sqlalchemy_url_to_django_db
36+
37+
db = _sqlalchemy_url_to_django_db("sqlite:///path/to/db.sqlite3")
38+
assert db["ENGINE"] == "django.db.backends.sqlite3"
39+
assert "db.sqlite3" in db["NAME"]
40+
41+
42+
def test_sqlite_memory_url():
43+
from pytest_mrt.adapters.django_runner import _sqlalchemy_url_to_django_db
44+
45+
db = _sqlalchemy_url_to_django_db("sqlite:///:memory:")
46+
assert db["ENGINE"] == "django.db.backends.sqlite3"
47+
assert db["NAME"] == ":memory:"
48+
49+
50+
def test_postgresql_full_url():
51+
from pytest_mrt.adapters.django_runner import _sqlalchemy_url_to_django_db
52+
53+
db = _sqlalchemy_url_to_django_db("postgresql://user:secret@db.host:5432/mydb")
54+
assert db["ENGINE"] == "django.db.backends.postgresql"
55+
assert db["NAME"] == "mydb"
56+
assert db["HOST"] == "db.host"
57+
assert db["PORT"] == "5432"
58+
assert db["USER"] == "user"
59+
assert db["PASSWORD"] == "secret"
60+
61+
62+
def test_postgresql_no_auth_or_port():
63+
from pytest_mrt.adapters.django_runner import _sqlalchemy_url_to_django_db
64+
65+
db = _sqlalchemy_url_to_django_db("postgresql:///localdb")
66+
assert db["ENGINE"] == "django.db.backends.postgresql"
67+
assert db["NAME"] == "localdb"
68+
assert "HOST" not in db
69+
assert "PORT" not in db
70+
assert "USER" not in db
71+
72+
73+
def test_mysql_url():
74+
from pytest_mrt.adapters.django_runner import _sqlalchemy_url_to_django_db
75+
76+
db = _sqlalchemy_url_to_django_db("mysql://user:pw@localhost/mydb")
77+
assert db["ENGINE"] == "django.db.backends.mysql"
78+
assert db["NAME"] == "mydb"
79+
80+
81+
def test_mssql_url():
82+
from pytest_mrt.adapters.django_runner import _sqlalchemy_url_to_django_db
83+
84+
db = _sqlalchemy_url_to_django_db("mssql+pymssql://user:pw@localhost/mydb")
85+
assert db["ENGINE"] == "mssql"
86+
87+
88+
def test_oracle_url():
89+
from pytest_mrt.adapters.django_runner import _sqlalchemy_url_to_django_db
90+
91+
db = _sqlalchemy_url_to_django_db("oracle+cx_oracle://user:pw@localhost:1521/orcl")
92+
assert db["ENGINE"] == "django.db.backends.oracle"
93+
94+
95+
# ── _configure_django helpers ─────────────────────────────────────────────
96+
97+
def _make_django_mocks(configured: bool = False):
98+
mock_settings = mock.MagicMock()
99+
mock_settings.configured = configured
100+
mock_settings.DATABASES = {"default": {}}
101+
102+
mock_django = mock.MagicMock()
103+
mock_conf = mock.MagicMock()
104+
mock_conf.settings = mock_settings
105+
106+
return (
107+
{"django": mock_django, "django.conf": mock_conf},
108+
mock_django,
109+
mock_settings,
110+
)
111+
112+
113+
# ── _configure_django ─────────────────────────────────────────────────────
114+
115+
def test_configure_already_configured():
116+
"""Returns early without calling setup() when settings are already configured."""
117+
from pytest_mrt.adapters.django_runner import _configure_django
118+
119+
mods, mock_django, _ = _make_django_mocks(configured=True)
120+
with mock.patch.dict("sys.modules", mods):
121+
_configure_django("sqlite:///:memory:", None, None, [])
122+
123+
mock_django.setup.assert_not_called()
124+
125+
126+
def test_configure_no_settings_module():
127+
"""Calls configure() + setup() when no settings module is given."""
128+
from pytest_mrt.adapters.django_runner import _configure_django
129+
130+
mods, mock_django, mock_settings = _make_django_mocks(configured=False)
131+
with mock.patch.dict("sys.modules", mods):
132+
_configure_django("sqlite:///:memory:", None, None, ["myapp"])
133+
134+
mock_settings.configure.assert_called_once()
135+
mock_django.setup.assert_called_once()
136+
137+
138+
def test_configure_with_settings_module(monkeypatch):
139+
"""With settings_module, calls setup() and updates DATABASES."""
140+
from pytest_mrt.adapters.django_runner import _configure_django
141+
142+
monkeypatch.delenv("DJANGO_SETTINGS_MODULE", raising=False)
143+
mods, mock_django, _ = _make_django_mocks(configured=False)
144+
with mock.patch.dict("sys.modules", mods):
145+
_configure_django("sqlite:///:memory:", "myproject.settings", None, [])
146+
147+
mock_django.setup.assert_called_once()
148+
149+
150+
def test_configure_with_project_dir(tmp_path):
151+
"""project_dir is prepended to sys.path."""
152+
from pytest_mrt.adapters.django_runner import _configure_django
153+
154+
mods, _, _ = _make_django_mocks(configured=False)
155+
with mock.patch.dict("sys.modules", mods):
156+
_configure_django("sqlite:///:memory:", None, str(tmp_path), [])
157+
158+
assert str(tmp_path) in sys.path
159+
while str(tmp_path) in sys.path:
160+
sys.path.remove(str(tmp_path))
161+
162+
163+
def test_configure_import_error():
164+
"""Raises ImportError with install hint when Django is not available."""
165+
from pytest_mrt.adapters.django_runner import _configure_django
166+
167+
with mock.patch.dict("sys.modules", {"django": None}):
168+
with pytest.raises(ImportError, match="pip install django"):
169+
_configure_django("sqlite:///:memory:", None, None, [])
170+
171+
172+
# ── DjangoMigrationRunner fixture ────────────────────────────────────────
173+
174+
@pytest.fixture
175+
def runner():
176+
"""DjangoMigrationRunner with Django and SQLAlchemy dependencies mocked."""
177+
with mock.patch("pytest_mrt.adapters.django_runner._configure_django"), \
178+
mock.patch("pytest_mrt.adapters.django_runner.create_engine") as mock_ce:
179+
mock_ce.return_value = mock.MagicMock()
180+
from pytest_mrt.adapters.django_runner import DjangoMigrationRunner
181+
182+
return DjangoMigrationRunner("sqlite:///:memory:")
183+
184+
185+
def _attach_executor(runner, *, leaf_plans=None, node_parent=None, node_missing=False, applied=None):
186+
"""Attach a configured mock _executor() to *runner* and return the mock executor."""
187+
mock_exec = mock.MagicMock()
188+
189+
if leaf_plans is not None:
190+
mock_exec.loader.graph.leaf_nodes.return_value = list(leaf_plans.keys())
191+
mock_exec.loader.graph.forwards_plan.side_effect = lambda leaf: leaf_plans[leaf]
192+
193+
if applied is not None:
194+
mock_exec.loader.applied_migrations = {k: None for k in applied}
195+
196+
if node_missing:
197+
mock_exec.loader.graph.node_map.get.return_value = None
198+
elif node_parent is not None:
199+
mock_node = mock.MagicMock()
200+
mock_node.parents = [node_parent]
201+
mock_exec.loader.graph.node_map.get.return_value = mock_node
202+
else:
203+
mock_node = mock.MagicMock()
204+
mock_node.parents = []
205+
mock_exec.loader.graph.node_map.get.return_value = mock_node
206+
207+
runner._executor = mock.MagicMock(return_value=mock_exec)
208+
return mock_exec
209+
210+
211+
# ── DjangoMigrationRunner methods ────────────────────────────────────────
212+
213+
def test_runner_upgrade(runner):
214+
mock_exec = _attach_executor(runner)
215+
runner.upgrade("myapp", "0001_initial")
216+
mock_exec.migrate.assert_called_once_with([("myapp", "0001_initial")])
217+
218+
219+
def test_runner_downgrade_to_parent(runner):
220+
"""downgrade() targets the first same-app parent."""
221+
mock_parent = mock.MagicMock()
222+
mock_parent.key = ("myapp", "0000_squashed")
223+
224+
mock_exec = _attach_executor(runner, node_parent=mock_parent)
225+
mock_exec.loader.graph.node_map.get.return_value.parents = [mock_parent]
226+
227+
runner.downgrade("myapp", "0001_initial")
228+
mock_exec.migrate.assert_called_once_with([("myapp", "0000_squashed")])
229+
230+
231+
def test_runner_downgrade_to_zero_when_no_same_app_parent(runner):
232+
"""downgrade() targets (app, None) when parent is from a different app."""
233+
mock_parent = mock.MagicMock()
234+
mock_parent.key = ("otherapp", "0001_dep")
235+
236+
mock_exec = _attach_executor(runner, node_parent=mock_parent)
237+
mock_exec.loader.graph.node_map.get.return_value.parents = [mock_parent]
238+
239+
runner.downgrade("myapp", "0001_initial")
240+
mock_exec.migrate.assert_called_once_with([("myapp", None)])
241+
242+
243+
def test_runner_downgrade_no_parents_at_all(runner):
244+
"""downgrade() targets (app, None) when the migration has no parents."""
245+
mock_exec = _attach_executor(runner) # node_parent=None → parents=[]
246+
mock_exec.loader.graph.node_map.get.return_value.parents = []
247+
248+
runner.downgrade("myapp", "0001_initial")
249+
mock_exec.migrate.assert_called_once_with([("myapp", None)])
250+
251+
252+
def test_runner_downgrade_migration_not_found(runner):
253+
"""downgrade() raises KeyError when migration is absent from the graph."""
254+
_attach_executor(runner, node_missing=True)
255+
256+
with pytest.raises(KeyError, match="myapp/0001_initial"):
257+
runner.downgrade("myapp", "0001_initial")
258+
259+
260+
def test_runner_downgrade_app_zero(runner):
261+
mock_exec = _attach_executor(runner)
262+
runner.downgrade_app_zero("myapp")
263+
mock_exec.migrate.assert_called_once_with([("myapp", None)])
264+
265+
266+
def test_runner_get_migrations_all(runner):
267+
"""get_migrations() returns all migrations in topological order."""
268+
key1 = ("myapp", "0001_initial")
269+
key2 = ("myapp", "0002_add_field")
270+
_attach_executor(runner, leaf_plans={key2: [key1, key2]})
271+
272+
from pytest_mrt.adapters.django_runner import DjangoMigration
273+
274+
result = runner.get_migrations()
275+
assert len(result) == 2
276+
assert all(isinstance(m, DjangoMigration) for m in result)
277+
assert result[0].name == "0001_initial"
278+
assert result[1].name == "0002_add_field"
279+
280+
281+
def test_runner_get_migrations_deduplicates(runner):
282+
"""get_migrations() deduplicates keys that appear in multiple leaf plans."""
283+
key1 = ("myapp", "0001_initial")
284+
key2 = ("myapp", "0002_add")
285+
# Two leaves that share key1 in their plan
286+
_attach_executor(runner, leaf_plans={key2: [key1, key2], key1: [key1]})
287+
288+
result = runner.get_migrations()
289+
names = [m.name for m in result]
290+
assert names.count("0001_initial") == 1
291+
292+
293+
def test_runner_get_migrations_filtered_by_app(runner):
294+
"""get_migrations(apps=[...]) excludes other app labels."""
295+
key1 = ("myapp", "0001_initial")
296+
key2 = ("otherapp", "0001_initial")
297+
_attach_executor(runner, leaf_plans={key1: [key1], key2: [key2]})
298+
299+
result = runner.get_migrations(apps=["myapp"])
300+
assert all(m.app_label == "myapp" for m in result)
301+
assert len(result) == 1
302+
303+
304+
def test_runner_current_state(runner):
305+
"""current_state() returns the set of applied migration keys."""
306+
applied = {("myapp", "0001_initial"), ("myapp", "0002_add")}
307+
_attach_executor(runner, applied=applied)
308+
309+
assert runner.current_state() == applied
310+
311+
312+
def test_runner_dispose(runner):
313+
"""dispose() calls engine.dispose() and closes the default DB connection."""
314+
mock_conn = mock.MagicMock()
315+
mock_connections = mock.MagicMock()
316+
mock_connections.__getitem__ = mock.MagicMock(return_value=mock_conn)
317+
318+
mock_db = mock.MagicMock()
319+
mock_db.connections = mock_connections
320+
321+
with mock.patch.dict("sys.modules", {"django.db": mock_db}):
322+
runner.dispose()
323+
324+
runner.engine.dispose.assert_called_once()
325+
mock_conn.close.assert_called_once()

0 commit comments

Comments
 (0)