diff --git a/docs/helpers.rst b/docs/helpers.rst index c9e189dd..69a17761 100644 --- a/docs/helpers.rst +++ b/docs/helpers.rst @@ -7,11 +7,7 @@ Assertions ---------- All of Django's :class:`~django:django.test.TestCase` -:ref:`django:assertions` are available in ``pytest_django.asserts``, e.g. - -:: - - from pytest_django.asserts import assertTemplateUsed +:ref:`django:assertions` are available in via the :fixture:`django_testcase` fixture. Markers ------- @@ -284,6 +280,27 @@ Example Using the `admin_client` fixture will cause the test to automatically be marked for database use (no need to specify the :func:`~pytest.mark.django_db` mark). +.. fixture:: django_testcase + +``django_testcase`` - Django test case assertions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Instance of the test case class. This fixture is particularly useful when you want +to use Django's specialized assertions for testing web applications. + +Example +""""""" + +:: + + def test_add(django_testcase): + django_testcase.assertEqual(1 + 1, 2) + django_testcase.assertXMLEqual(..., ...) + django_testcase.assertJSONEqual(..., ...) + + with django_testcase.assertNumQueries(2): + some_function() + .. fixture:: admin_user ``admin_user`` - an admin user (superuser) diff --git a/pytest_django/asserts.py b/pytest_django/asserts.py index 14741066..ad456d20 100644 --- a/pytest_django/asserts.py +++ b/pytest_django/asserts.py @@ -4,6 +4,7 @@ from __future__ import annotations +import warnings from functools import wraps from typing import TYPE_CHECKING, Any, Callable, Sequence @@ -30,6 +31,15 @@ def _wrapper(name: str): @wraps(func) def assertion_func(*args, **kwargs): + message = ( + f"Using pytest_django.asserts.{name} is deprecated. " + f'Use fixture "django_testcase" and django_testcase.{name} instead.' + ) + warnings.warn( + message, + DeprecationWarning, + stacklevel=2, + ) return func(*args, **kwargs) return assertion_func diff --git a/pytest_django/fixtures.py b/pytest_django/fixtures.py index 6dc05fdb..496ff385 100644 --- a/pytest_django/fixtures.py +++ b/pytest_django/fixtures.py @@ -56,6 +56,8 @@ "django_db_reset_sequences", "django_db_serialized_rollback", "django_db_setup", + "django_testcase", + "django_testcase_class", "django_user_model", "django_username_field", "live_server", @@ -216,15 +218,15 @@ def django_db_setup( @pytest.fixture() -def _django_db_helper( +def django_testcase_class( request: pytest.FixtureRequest, - django_db_setup: None, - django_db_blocker: DjangoDbBlocker, -) -> Generator[None, None, None]: +) -> Generator[type[django.test.TestCase] | None, None, None]: if is_django_unittest(request): - yield + yield None return + import django.test + marker = request.node.get_closest_marker("django_db") if marker: ( @@ -253,63 +255,74 @@ def _django_db_helper( "django_db_serialized_rollback" in request.fixturenames ) + if transactional: + test_case_class = django.test.TransactionTestCase + else: + test_case_class = django.test.TestCase + + _reset_sequences = reset_sequences + _serialized_rollback = serialized_rollback + _databases = databases + _available_apps = available_apps + + class PytestDjangoTestCase(test_case_class): # type: ignore[misc,valid-type] + reset_sequences = _reset_sequences + serialized_rollback = _serialized_rollback + if _databases is not None: + databases = _databases + if _available_apps is not None: + available_apps = _available_apps + + # For non-transactional tests, skip executing `django.test.TestCase`'s + # `setUpClass`/`tearDownClass`, only execute the super class ones. + # + # `TestCase`'s class setup manages the `setUpTestData`/class-level + # transaction functionality. We don't use it; instead we (will) offer + # our own alternatives. So it only adds overhead, and does some things + # which conflict with our (planned) functionality, particularly, it + # closes all database connections in `tearDownClass` which inhibits + # wrapping tests in higher-scoped transactions. + # + # It's possible a new version of Django will add some unrelated + # functionality to these methods, in which case skipping them completely + # would not be desirable. Let's cross that bridge when we get there... + if not transactional: + + @classmethod + def setUpClass(cls) -> None: + super(django.test.TestCase, cls).setUpClass() + + @classmethod + def tearDownClass(cls) -> None: + super(django.test.TestCase, cls).tearDownClass() + + yield PytestDjangoTestCase + + +@pytest.fixture() +def _django_db_helper( + request: pytest.FixtureRequest, + django_db_setup: None, + django_db_blocker: DjangoDbBlocker, + django_testcase_class: type[django.test.TestCase], +) -> Generator[None, None, None]: + if is_django_unittest(request): + yield + return + with django_db_blocker.unblock(): - import django.db - import django.test + django_testcase_class.setUpClass() - if transactional: - test_case_class = django.test.TransactionTestCase - else: - test_case_class = django.test.TestCase - - _reset_sequences = reset_sequences - _serialized_rollback = serialized_rollback - _databases = databases - _available_apps = available_apps - - class PytestDjangoTestCase(test_case_class): # type: ignore[misc,valid-type] - reset_sequences = _reset_sequences - serialized_rollback = _serialized_rollback - if _databases is not None: - databases = _databases - if _available_apps is not None: - available_apps = _available_apps - - # For non-transactional tests, skip executing `django.test.TestCase`'s - # `setUpClass`/`tearDownClass`, only execute the super class ones. - # - # `TestCase`'s class setup manages the `setUpTestData`/class-level - # transaction functionality. We don't use it; instead we (will) offer - # our own alternatives. So it only adds overhead, and does some things - # which conflict with our (planned) functionality, particularly, it - # closes all database connections in `tearDownClass` which inhibits - # wrapping tests in higher-scoped transactions. - # - # It's possible a new version of Django will add some unrelated - # functionality to these methods, in which case skipping them completely - # would not be desirable. Let's cross that bridge when we get there... - if not transactional: - - @classmethod - def setUpClass(cls) -> None: - super(django.test.TestCase, cls).setUpClass() - - @classmethod - def tearDownClass(cls) -> None: - super(django.test.TestCase, cls).tearDownClass() - - PytestDjangoTestCase.setUpClass() - - test_case = PytestDjangoTestCase(methodName="__init__") + test_case = django_testcase_class(methodName="__init__") test_case._pre_setup() - yield + yield test_case test_case._post_teardown() - PytestDjangoTestCase.tearDownClass() + django_testcase_class.tearDownClass() - PytestDjangoTestCase.doClassCleanups() + django_testcase_class.doClassCleanups() def _django_db_signature( @@ -379,6 +392,11 @@ def _set_suffix_to_test_databases(suffix: str) -> None: # ############### User visible fixtures ################ +@pytest.fixture() +def django_testcase(_django_db_helper: django.test.TestCase | None) -> django.test.TestCase | None: + return _django_db_helper + + @pytest.fixture() def db(_django_db_helper: None) -> None: """Require a django test database. diff --git a/pytest_django/plugin.py b/pytest_django/plugin.py index e8e629f4..c946c525 100644 --- a/pytest_django/plugin.py +++ b/pytest_django/plugin.py @@ -40,6 +40,8 @@ django_db_serialized_rollback, # noqa: F401 django_db_setup, # noqa: F401 django_db_use_migrations, # noqa: F401 + django_testcase, # noqa: F401 + django_testcase_class, # noqa: F401 django_user_model, # noqa: F401 django_username_field, # noqa: F401 live_server, # noqa: F401 diff --git a/tests/test_asserts.py b/tests/test_asserts.py index c9a01ec7..2f4ed386 100644 --- a/tests/test_asserts.py +++ b/tests/test_asserts.py @@ -6,8 +6,11 @@ import inspect +import django.test import pytest +from .helpers import DjangoPytester + import pytest_django from pytest_django.asserts import __all__ as asserts_all @@ -71,3 +74,61 @@ def test_sanity() -> None: pass assert assertContains.__doc__ + + +def test_fixture_assert(django_testcase: django.test.TestCase) -> None: + django_testcase.assertEqual("a", "a") # noqa: PT009 + + with pytest.raises(AssertionError): + django_testcase.assertXMLEqual("a" * 10_000, "a") + + +class TestInternalDjangoAssert: + def test_fixture_assert(self, django_testcase: django.test.TestCase) -> None: + assert django_testcase != self + django_testcase.assertEqual("a", "a") # noqa: PT009 + assert not hasattr(self, "assertEqual") + + with pytest.raises(AssertionError): + django_testcase.assertXMLEqual("a" * 10_000, "a") + + +@pytest.mark.django_project(create_manage_py=True) +def test_django_test_case_assert(django_pytester: DjangoPytester) -> None: + django_pytester.create_test_module( + """ + import pytest + import django.test + + class TestDjangoAssert(django.test.TestCase): + def test_fixture_assert(self, django_testcase: django.test.TestCase) -> None: + assert False, "Cannot use the fixture" + + def test_normal_assert(self) -> None: + self.assertEqual("a", "a") + with pytest.raises(AssertionError): + self.assertXMLEqual("a" * 10_000, "a") + """ + ) + result = django_pytester.runpytest_subprocess() + result.assert_outcomes(failed=1, passed=1) + assert "missing 1 required positional argument: 'django_testcase'" in result.stdout.str() + + +@pytest.mark.django_project(create_manage_py=True) +def test_unittest_assert(django_pytester: DjangoPytester) -> None: + django_pytester.create_test_module( + """ + import unittest + + class TestUnittestAssert(unittest.TestCase): + def test_fixture_assert(self, django_testcase: unittest.TestCase) -> None: + assert False, "Cannot use the fixture" + + def test_normal_assert(self) -> None: + self.assertEqual("a", "a") + """ + ) + result = django_pytester.runpytest_subprocess() + result.assert_outcomes(failed=1, passed=1) + assert "missing 1 required positional argument: 'django_testcase'" in result.stdout.str()