Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ Improvements
^^^^^^^^^^^^

* The :ref:`multiple databases <multi-db>` support added in v4.3.0 is no longer considered experimental.
* Added :func:`@pytest.mark.django_isolate_apps <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)
--------------------
Expand Down
31 changes: 31 additions & 0 deletions docs/helpers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <django.test.utils.isolate_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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down Expand Up @@ -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([...]) <pytest.mark.django_isolate_apps>`.

.. fixture:: db

``db``
Expand Down
49 changes: 49 additions & 0 deletions pytest_django/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
from typing import Any, NoReturn

import django
import django.apps.registry


SETTINGS_MODULE_ENV = "DJANGO_SETTINGS_MODULE"
Expand Down Expand Up @@ -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 "
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
100 changes: 100 additions & 0 deletions tests/test_isolate_apps.py
Original file line number Diff line number Diff line change
@@ -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*"
]
)