Repository navigation
Add support for db fixture (test inside transaction) for asyncio tests #1223
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,7 +3,7 @@ | |
| from __future__ import annotations | ||
|
|
||
| import os | ||
| from collections.abc import Callable, Generator, Iterable, Sequence | ||
| from collections.abc import AsyncGenerator, Callable, Generator, Iterable, Sequence | ||
| from contextlib import AbstractContextManager, contextmanager | ||
| from functools import partial | ||
| from typing import TYPE_CHECKING, Any, Protocol | ||
|
|
@@ -203,8 +203,62 @@ def django_db_setup( # noqa: PLR0917 | |
| ) | ||
|
|
||
|
|
||
| def _build_pytest_django_test_case( | ||
| *, | ||
| reset_sequences: bool, | ||
| serialized_rollback: bool, | ||
| databases: _DjangoDbDatabases, | ||
| available_apps: _DjangoDbAvailableApps, | ||
| transactional: bool, | ||
| ) -> type[django.test.TestCase]: | ||
| import django.test | ||
|
|
||
| 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() | ||
|
|
||
| return PytestDjangoTestCase | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def _django_db_helper( | ||
| def _sync_django_db_helper( | ||
| request: pytest.FixtureRequest, | ||
| django_db_setup: None, # noqa: ARG001 | ||
| django_db_blocker: DjangoDbBlocker, | ||
|
|
@@ -242,49 +296,13 @@ def _django_db_helper( | |
| ) | ||
|
|
||
| with django_db_blocker.unblock(): | ||
| import django.db | ||
| import django.test | ||
|
|
||
| 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 = _build_pytest_django_test_case( | ||
| reset_sequences=reset_sequences, | ||
| serialized_rollback=serialized_rollback, | ||
| databases=databases, | ||
| available_apps=available_apps, | ||
| transactional=transactional, | ||
| ) | ||
|
|
||
| PytestDjangoTestCase.setUpClass() | ||
|
|
||
|
|
@@ -303,6 +321,106 @@ def tearDownClass(cls) -> None: | |
| PytestDjangoTestCase.doClassCleanups() | ||
|
|
||
|
|
||
| try: | ||
| import pytest_asyncio | ||
| except ImportError: | ||
|
|
||
| async def _async_django_db_helper( | ||
| request: pytest.FixtureRequest, # noqa: ARG001 | ||
| django_db_blocker: DjangoDbBlocker, # noqa: ARG001 | ||
| ) -> AsyncGenerator[None]: | ||
| raise RuntimeError( | ||
| "The `pytest_asyncio` plugin is required to use the `async_django_db` fixture." | ||
| ) | ||
| yield # pragma: no cover | ||
| else: | ||
|
|
||
| @pytest_asyncio.fixture | ||
| async def _async_django_db_helper( | ||
| request: pytest.FixtureRequest, | ||
| django_db_blocker: DjangoDbBlocker, | ||
| ) -> AsyncGenerator[None]: | ||
| # same as _sync_django_db_helper, except for running the transaction start and rollback wrapped in a | ||
| # `sync_to_async` call | ||
| _transactional, reset_sequences, databases, serialized_rollback, available_apps = ( | ||
| _get_django_db_settings(request) | ||
| ) | ||
|
|
||
| with django_db_blocker.unblock(async_only=True): | ||
| PytestDjangoTestCase = _build_pytest_django_test_case( | ||
| reset_sequences=reset_sequences, | ||
| serialized_rollback=serialized_rollback, | ||
| databases=databases, | ||
| available_apps=available_apps, | ||
| transactional=False, | ||
| ) | ||
|
|
||
| from asgiref.sync import sync_to_async | ||
|
|
||
| await sync_to_async(PytestDjangoTestCase.setUpClass)() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have a vague idea that, since pytest-asyncio and similar plugins already the sync/async-fixtures-within-async-test case by running them in the appropriate event loop, if we can make this stuff "just work" by running the django setup/teardown fixtures as pytest fixtures. I'll see if it's viable and get back to you. |
||
|
|
||
| test_case = PytestDjangoTestCase(methodName="__init__") | ||
| await sync_to_async(test_case._pre_setup, thread_sensitive=True)() | ||
|
|
||
| yield | ||
|
|
||
| await sync_to_async(test_case._post_teardown, thread_sensitive=True)() | ||
|
|
||
| await sync_to_async(PytestDjangoTestCase.tearDownClass)() | ||
|
|
||
| await sync_to_async(PytestDjangoTestCase.doClassCleanups)() | ||
|
|
||
|
|
||
| def _get_django_db_settings(request: pytest.FixtureRequest) -> _DjangoDb: | ||
| django_marker = request.node.get_closest_marker("django_db") | ||
| if django_marker: | ||
| ( | ||
| transactional, | ||
| reset_sequences, | ||
| databases, | ||
| serialized_rollback, | ||
| available_apps, | ||
| ) = validate_django_db(django_marker) | ||
| else: | ||
| ( | ||
| transactional, | ||
| reset_sequences, | ||
| databases, | ||
| serialized_rollback, | ||
| available_apps, | ||
| ) = False, False, None, False, None | ||
|
|
||
| transactional = ( | ||
| transactional | ||
| or reset_sequences | ||
| or ("transactional_db" in request.fixturenames or "live_server" in request.fixturenames) | ||
| ) | ||
|
|
||
| reset_sequences = reset_sequences or ("django_db_reset_sequences" in request.fixturenames) | ||
| serialized_rollback = serialized_rollback or ( | ||
| "django_db_serialized_rollback" in request.fixturenames | ||
| ) | ||
| return transactional, reset_sequences, databases, serialized_rollback, available_apps | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def _django_db_helper( | ||
| request: pytest.FixtureRequest, | ||
| django_db_setup: None, # noqa: ARG001 | ||
| django_db_blocker: DjangoDbBlocker, # noqa: ARG001 | ||
| ) -> None: | ||
| asyncio_marker = request.node.get_closest_marker("asyncio") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we're going all in on pytest-asyncio, would be better to use Although there are several async-support plugins for pytest, e.g. |
||
| transactional, *_ = _get_django_db_settings(request) | ||
| if transactional or not asyncio_marker: | ||
| # add the original sync fixture | ||
| request.getfixturevalue("_sync_django_db_helper") | ||
| else: | ||
| # add the async fixture. Will run it inside the event loop, which will cause the sync to async calls to | ||
| # start a transaction on the thread safe executor for that loop. This allows us to roll back orm calls made | ||
| # in that async test context. | ||
| request.getfixturevalue("_async_django_db_helper") | ||
|
|
||
|
|
||
| def _django_db_signature( | ||
| transaction: bool = False, | ||
| reset_sequences: bool = False, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.