Skip to content

Commit ee7f27d

Browse files
committed
Add support for isolating apps in tests
Fix #1253.
1 parent 39c8dcc commit ee7f27d

4 files changed

Lines changed: 158 additions & 0 deletions

File tree

docs/changelog.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ Improvements
1515
^^^^^^^^^^^^
1616

1717
* The :ref:`multiple databases <multi-db>` support added in v4.3.0 is no longer considered experimental.
18+
* Added :func:`@pytest.mark.django_isolate_apps <pytest.mark.django_isolate_apps>`
19+
for isolating Django's app registry in pytest tests, and a
20+
:fixture:`django_isolated_apps` fixture to access the isolated Apps registry instance if needed.
1821

1922
v4.11.1 (2025-04-03)
2023
--------------------

docs/helpers.rst

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,29 @@ dynamically in a hook or fixture.
132132
assert b'Success!' in client.get('/some_url_defined_in_test_urls/').content
133133

134134

135+
``pytest.mark.django_isolate_apps`` - isolate the app registry
136+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
137+
138+
.. decorator:: pytest.mark.django_isolate_apps(*app_labels)
139+
140+
Isolate models defined within the marked tests into their own isolated apps registry.
141+
See :func:`Isolating apps <django.test.utils.isolate_apps>` for when this might be useful.
142+
143+
:type app_labels: str
144+
:param app_labels:
145+
One or more application labels to include in the isolated registry.
146+
147+
The :fixture:`django_isolated_apps` fixture provides access to the isolated
148+
apps registry instance, if needed.
149+
150+
Example usage::
151+
152+
@pytest.mark.django_isolate_apps("myapp")
153+
def test_something(django_isolated_apps):
154+
assert django_isolated_apps.is_installed("myapp")
155+
assert not django_isolated_apps.is_installed("otherapp")
156+
157+
135158
``pytest.mark.ignore_template_errors`` - ignore invalid template variables
136159
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
137160

@@ -317,6 +340,14 @@ resolves to the user model's :attr:`~django.contrib.auth.models.CustomUser.USERN
317340
Use this fixture to make pluggable apps testable regardless what the username field
318341
is configured to be in the containing Django project.
319342

343+
.. fixture:: django_isolated_apps
344+
345+
``django_isolated_apps`` - isolated app registry
346+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
347+
348+
Access the isolated app registry created by
349+
:func:`@pytest.mark.django_isolate_apps([...]) <pytest.mark.django_isolate_apps>`.
350+
320351
.. fixture:: db
321352

322353
``db``

pytest_django/plugin.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
from typing import Any, NoReturn
5858

5959
import django
60+
import django.apps.registry
6061

6162

6263
SETTINGS_MODULE_ENV = "DJANGO_SETTINGS_MODULE"
@@ -294,6 +295,10 @@ def pytest_load_initial_conftests(
294295
"a string specifying the module of a URL config, e.g. "
295296
'"my_app.test_urls".',
296297
)
298+
early_config.addinivalue_line(
299+
"markers",
300+
"django_isolate_apps(*app_labels): isolate Django's app registry for this test.",
301+
)
297302
early_config.addinivalue_line(
298303
"markers",
299304
"ignore_template_errors(): ignore errors from invalid template "
@@ -669,6 +674,39 @@ def _django_set_urlconf(request: pytest.FixtureRequest) -> Generator[None, None,
669674
set_urlconf(None)
670675

671676

677+
@pytest.fixture(autouse=True)
678+
def _django_isolate_apps(
679+
request: pytest.FixtureRequest,
680+
) -> Generator[django.apps.registry.Apps, None, None]:
681+
"""Apply the @pytest.mark.django_isolate_apps marker if present, internal to pytest-django."""
682+
marker: pytest.Mark | None = request.node.get_closest_marker("django_isolate_apps")
683+
if not marker:
684+
yield None
685+
return
686+
687+
skip_if_no_django()
688+
689+
from django.test.utils import isolate_apps
690+
691+
app_labels = validate_django_isolate_apps(marker)
692+
693+
with isolate_apps(*app_labels) as apps:
694+
yield apps
695+
696+
697+
@pytest.fixture
698+
def django_isolated_apps(
699+
_django_isolate_apps: django.apps.registry.Apps | None,
700+
) -> django.apps.registry.Apps:
701+
"""Access the isolated Apps registry instance for tests marked with
702+
@pytest.mark.django_isolate_apps(...)."""
703+
if _django_isolate_apps is None:
704+
raise pytest.UsageError(
705+
"The django_isolated_apps fixture requires @pytest.mark.django_isolate_apps([...])."
706+
)
707+
return _django_isolate_apps
708+
709+
672710
@pytest.fixture(autouse=True, scope="session")
673711
def _fail_for_invalid_template_variable() -> Generator[None, None, None]:
674712
"""Fixture that fails for invalid variables in templates.
@@ -887,3 +925,14 @@ def apifun(urls: list[str]) -> list[str]:
887925
return urls
888926

889927
return apifun(*marker.args, **marker.kwargs)
928+
929+
930+
def validate_django_isolate_apps(marker: pytest.Mark) -> tuple[str, ...]:
931+
"""Validate the django_isolate_apps marker."""
932+
933+
def apifun(*app_labels: str) -> tuple[str, ...]:
934+
if not app_labels:
935+
raise ValueError("@pytest.mark.django_isolate_apps requires at least one app label")
936+
return app_labels
937+
938+
return apifun(*marker.args, **marker.kwargs)

tests/test_isolate_apps.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from .helpers import DjangoPytester
2+
3+
4+
def test_django_isolate_apps_marker(django_pytester: DjangoPytester) -> None:
5+
django_pytester.create_test_module(
6+
"""
7+
import pytest
8+
9+
10+
@pytest.mark.django_isolate_apps("tpkg.app")
11+
def test_isolated_registry_fixture(django_isolated_apps):
12+
assert django_isolated_apps.is_installed("tpkg.app")
13+
assert not django_isolated_apps.is_installed("django.contrib.auth")
14+
15+
16+
@pytest.mark.django_isolate_apps("tpkg.app", "django.contrib.auth")
17+
def test_isolated_registry_multiple_apps(django_isolated_apps):
18+
assert django_isolated_apps.is_installed("tpkg.app")
19+
assert django_isolated_apps.is_installed("django.contrib.auth")
20+
21+
22+
@pytest.mark.django_isolate_apps("tpkg.app")
23+
class TestIsolatedRegistryClass:
24+
def test_first(self, django_isolated_apps):
25+
assert django_isolated_apps.is_installed("tpkg.app")
26+
27+
def test_second(self, django_isolated_apps):
28+
assert not django_isolated_apps.is_installed("django.contrib.auth")
29+
30+
31+
def test_global_registry_is_unchanged():
32+
from django.apps import apps
33+
34+
assert apps.is_installed("django.contrib.auth")
35+
"""
36+
)
37+
38+
result = django_pytester.runpytest_subprocess("-v")
39+
result.assert_outcomes(passed=5)
40+
41+
42+
def test_django_isolate_apps_marker_requires_labels(django_pytester: DjangoPytester) -> None:
43+
django_pytester.create_test_module(
44+
"""
45+
import pytest
46+
47+
48+
@pytest.mark.django_isolate_apps()
49+
def test_isolated_registry_requires_labels():
50+
pass
51+
"""
52+
)
53+
54+
result = django_pytester.runpytest_subprocess("-v")
55+
result.assert_outcomes(errors=1)
56+
result.stdout.fnmatch_lines(
57+
["*ValueError: @pytest.mark.django_isolate_apps requires at least one app label*"]
58+
)
59+
60+
61+
def test_django_isolated_apps_fixture_requires_marker(django_pytester: DjangoPytester) -> None:
62+
django_pytester.create_test_module(
63+
"""
64+
def test_requires_marker(django_isolated_apps):
65+
pass
66+
"""
67+
)
68+
69+
result = django_pytester.runpytest_subprocess("-v")
70+
result.assert_outcomes(errors=1)
71+
result.stdout.fnmatch_lines(
72+
[
73+
"*UsageError: The django_isolated_apps fixture requires @pytest.mark.django_isolate_apps*"
74+
]
75+
)

0 commit comments

Comments
 (0)