From acea05151f7842a8ea4a85d9e17ed37fe43db256 Mon Sep 17 00:00:00 2001 From: Ran Benita Date: Sat, 14 Feb 2026 12:51:26 +0200 Subject: [PATCH] Add support for isolating apps in tests Fix #1253. --- docs/changelog.rst | 3 ++ docs/helpers.rst | 31 ++++++++++++ pytest_django/plugin.py | 49 ++++++++++++++++++ tests/test_isolate_apps.py | 100 +++++++++++++++++++++++++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 tests/test_isolate_apps.py diff --git a/docs/changelog.rst b/docs/changelog.rst index 5a83b61e..fa3972b5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,9 @@ Improvements ^^^^^^^^^^^^ * The :ref:`multiple databases ` support added in v4.3.0 is no longer considered experimental. +* Added :func:`@pytest.mark.django_isolate_apps ` + for isolating Django's app registry in pytest tests, and a + :fixture:`django_isolated_apps` fixture to access the isolated Apps registry instance if needed. v4.11.1 (2025-04-03) -------------------- diff --git a/docs/helpers.rst b/docs/helpers.rst index a1a6a59a..4d129dad 100644 --- a/docs/helpers.rst +++ b/docs/helpers.rst @@ -132,6 +132,29 @@ dynamically in a hook or fixture. assert b'Success!' in client.get('/some_url_defined_in_test_urls/').content +``pytest.mark.django_isolate_apps`` - isolate the app registry +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. decorator:: pytest.mark.django_isolate_apps(*app_labels) + + Isolate models defined within the marked tests into their own isolated apps registry. + See :func:`Isolating apps ` for when this might be useful. + + :type app_labels: str + :param app_labels: + One or more application labels to include in the isolated registry. + + The :fixture:`django_isolated_apps` fixture provides access to the isolated + apps registry instance, if needed. + + Example usage:: + + @pytest.mark.django_isolate_apps("myapp") + def test_something(django_isolated_apps): + assert django_isolated_apps.is_installed("myapp") + assert not django_isolated_apps.is_installed("otherapp") + + ``pytest.mark.ignore_template_errors`` - ignore invalid template variables ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -317,6 +340,14 @@ resolves to the user model's :attr:`~django.contrib.auth.models.CustomUser.USERN Use this fixture to make pluggable apps testable regardless what the username field is configured to be in the containing Django project. +.. fixture:: django_isolated_apps + +``django_isolated_apps`` - isolated app registry +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Access the isolated app registry created by +:func:`@pytest.mark.django_isolate_apps([...]) `. + .. fixture:: db ``db`` diff --git a/pytest_django/plugin.py b/pytest_django/plugin.py index fbf46771..2866eca8 100644 --- a/pytest_django/plugin.py +++ b/pytest_django/plugin.py @@ -57,6 +57,7 @@ from typing import Any, NoReturn import django + import django.apps.registry SETTINGS_MODULE_ENV = "DJANGO_SETTINGS_MODULE" @@ -294,6 +295,10 @@ def pytest_load_initial_conftests( "a string specifying the module of a URL config, e.g. " '"my_app.test_urls".', ) + early_config.addinivalue_line( + "markers", + "django_isolate_apps(*app_labels): isolate Django's app registry for this test.", + ) early_config.addinivalue_line( "markers", "ignore_template_errors(): ignore errors from invalid template " @@ -669,6 +674,39 @@ def _django_set_urlconf(request: pytest.FixtureRequest) -> Generator[None, None, set_urlconf(None) +@pytest.fixture(autouse=True) +def _django_isolate_apps( + request: pytest.FixtureRequest, +) -> Generator[django.apps.registry.Apps, None, None]: + """Apply the @pytest.mark.django_isolate_apps marker if present, internal to pytest-django.""" + marker: pytest.Mark | None = request.node.get_closest_marker("django_isolate_apps") + if not marker: + yield None + return + + skip_if_no_django() + + from django.test.utils import isolate_apps + + app_labels = validate_django_isolate_apps(marker) + + with isolate_apps(*app_labels) as apps: + yield apps + + +@pytest.fixture +def django_isolated_apps( + _django_isolate_apps: django.apps.registry.Apps | None, +) -> django.apps.registry.Apps: + """Access the isolated Apps registry instance for tests marked with + @pytest.mark.django_isolate_apps(...).""" + if _django_isolate_apps is None: + raise pytest.UsageError( + "The django_isolated_apps fixture requires @pytest.mark.django_isolate_apps([...])." + ) + return _django_isolate_apps + + @pytest.fixture(autouse=True, scope="session") def _fail_for_invalid_template_variable() -> Generator[None, None, None]: """Fixture that fails for invalid variables in templates. @@ -887,3 +925,14 @@ def apifun(urls: list[str]) -> list[str]: return urls return apifun(*marker.args, **marker.kwargs) + + +def validate_django_isolate_apps(marker: pytest.Mark) -> tuple[str, ...]: + """Validate the django_isolate_apps marker.""" + + def apifun(*app_labels: str) -> tuple[str, ...]: + if not app_labels: + raise ValueError("@pytest.mark.django_isolate_apps requires at least one app label") + return app_labels + + return apifun(*marker.args, **marker.kwargs) diff --git a/tests/test_isolate_apps.py b/tests/test_isolate_apps.py new file mode 100644 index 00000000..df1c6bb4 --- /dev/null +++ b/tests/test_isolate_apps.py @@ -0,0 +1,100 @@ +from .helpers import DjangoPytester + + +def test_django_isolate_apps_marker(django_pytester: DjangoPytester) -> None: + django_pytester.create_test_module( + """ + import pytest + from django.apps import apps + + + @pytest.mark.django_isolate_apps("tpkg.app") + def test_isolated_registry_fixture(django_isolated_apps): + assert django_isolated_apps.is_installed("tpkg.app") + assert not django_isolated_apps.is_installed("django.contrib.auth") + + + @pytest.mark.django_isolate_apps("tpkg.app", "django.contrib.auth") + def test_isolated_registry_multiple_apps(django_isolated_apps): + assert django_isolated_apps.is_installed("tpkg.app") + assert django_isolated_apps.is_installed("django.contrib.auth") + + + @pytest.mark.django_isolate_apps("tpkg.app") + class TestIsolatedRegistryClass: + def test_first(self, django_isolated_apps): + assert django_isolated_apps.is_installed("tpkg.app") + + def test_second(self, django_isolated_apps): + assert not django_isolated_apps.is_installed("django.contrib.auth") + + + @pytest.mark.django_isolate_apps("tpkg.app") + def test_global_registry_is_unchanged(): + assert apps.is_installed("django.contrib.auth") + """ + ) + + result = django_pytester.runpytest_subprocess("-v") + result.assert_outcomes(passed=5) + + +def test_django_isolate_apps_is_functional(django_pytester: DjangoPytester) -> None: + django_pytester.create_test_module( + """ + import pytest + from django.db import models + from django.apps import apps + + + @pytest.mark.django_db + @pytest.mark.django_isolate_apps("tpkg.app") + def test_model_is_registered(django_isolated_apps): + class MyModel(models.Model): + class Meta: + app_label = "app" + + assert django_isolated_apps.get_model('app', 'MyModel') is not None + + with pytest.raises(LookupError): + apps.get_model('app', 'MyModel') + """ + ) + result = django_pytester.runpytest_subprocess("-v") + result.assert_outcomes(passed=1) + + +def test_django_isolate_apps_marker_requires_labels(django_pytester: DjangoPytester) -> None: + django_pytester.create_test_module( + """ + import pytest + + + @pytest.mark.django_isolate_apps() + def test_isolated_registry_requires_labels(): + pass + """ + ) + + result = django_pytester.runpytest_subprocess("-v") + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines( + ["*ValueError: @pytest.mark.django_isolate_apps requires at least one app label*"] + ) + + +def test_django_isolated_apps_fixture_requires_marker(django_pytester: DjangoPytester) -> None: + django_pytester.create_test_module( + """ + def test_requires_marker(django_isolated_apps): + pass + """ + ) + + result = django_pytester.runpytest_subprocess("-v") + result.assert_outcomes(errors=1) + result.stdout.fnmatch_lines( + [ + "*UsageError: The django_isolated_apps fixture requires @pytest.mark.django_isolate_apps*" + ] + )