Skip to content

Commit b90fea5

Browse files
committed
fixtures: add django_capture_on_commit_callbacks fixture
Similar to Django's `TestCase.captureOnCommitCallbacks`. Documentation is cribbed from there. Fixes #752.
1 parent 44fddc2 commit b90fea5

File tree

4 files changed

+148
-2
lines changed

4 files changed

+148
-2
lines changed

Diff for: docs/helpers.rst

+45
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,51 @@ Example usage::
425425
Item.objects.create('foo')
426426
Item.objects.create('bar')
427427

428+
429+
.. fixture:: django_capture_on_commit_callbacks
430+
431+
``django_capture_on_commit_callbacks``
432+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
433+
434+
.. py:function:: django_capture_on_commit_callbacks(*, using=DEFAULT_DB_ALIAS, execute=False)
435+
436+
:param using:
437+
The alias of the database connection to capture callbacks for.
438+
:param execute:
439+
If True, all the callbacks will be called as the context manager exits, if
440+
no exception occurred. This emulates a commit after the wrapped block of
441+
code.
442+
443+
.. versionadded:: 4.4
444+
445+
Returns a context manager that captures
446+
:func:`transaction.on_commit() <django.db.transaction.on_commit>` callbacks for
447+
the given database connection. It returns a list that contains, on exit of the
448+
context, the captured callback functions. From this list you can make assertions
449+
on the callbacks or call them to invoke their side effects, emulating a commit.
450+
451+
Avoid this fixture in tests using ``transaction=True``; you are not likely to
452+
get useful results.
453+
454+
This fixture is based on Django's :meth:`django.test.TestCase.captureOnCommitCallbacks`
455+
helper.
456+
457+
Example usage::
458+
459+
def test_on_commit(client, mailoutbox, django_capture_on_commit_callbacks):
460+
with django_capture_on_commit_callbacks(execute=True) as callbacks:
461+
response = client.post(
462+
'/contact/',
463+
{'message': 'I like your site'},
464+
)
465+
466+
assert response.status_code == 200
467+
assert len(callbacks) == 1
468+
assert len(mailoutbox) == 1
469+
assert mailoutbox[0].subject == 'Contact Form'
470+
assert mailoutbox[0].body == 'I like your site'
471+
472+
428473
.. fixture:: mailoutbox
429474

430475
``mailoutbox``

Diff for: pytest_django/fixtures.py

+38-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""All pytest-django fixtures"""
2-
from typing import Any, Generator, Iterable, List, Optional, Tuple, Union
2+
from typing import Any, Callable, Generator, Iterable, List, Optional, Tuple, Union
33
import os
44
from contextlib import contextmanager
55
from functools import partial
@@ -8,7 +8,7 @@
88

99
from . import live_server_helper
1010
from .django_compat import is_django_unittest
11-
from .lazy_django import skip_if_no_django
11+
from .lazy_django import skip_if_no_django, get_django_version
1212

1313
TYPE_CHECKING = False
1414
if TYPE_CHECKING:
@@ -38,6 +38,7 @@
3838
"_live_server_helper",
3939
"django_assert_num_queries",
4040
"django_assert_max_num_queries",
41+
"django_capture_on_commit_callbacks",
4142
]
4243

4344

@@ -542,3 +543,38 @@ def django_assert_num_queries(pytestconfig):
542543
@pytest.fixture(scope="function")
543544
def django_assert_max_num_queries(pytestconfig):
544545
return partial(_assert_num_queries, pytestconfig, exact=False)
546+
547+
548+
@contextmanager
549+
def _capture_on_commit_callbacks(
550+
*,
551+
using: Optional[str] = None,
552+
execute: bool = False
553+
):
554+
from django.db import DEFAULT_DB_ALIAS, connections
555+
from django.test import TestCase
556+
557+
if using is None:
558+
using = DEFAULT_DB_ALIAS
559+
560+
# Polyfill of Django code as of Django 3.2.
561+
if get_django_version() < (3, 2):
562+
callbacks = [] # type: List[Callable[[], Any]]
563+
start_count = len(connections[using].run_on_commit)
564+
try:
565+
yield callbacks
566+
finally:
567+
run_on_commit = connections[using].run_on_commit[start_count:]
568+
callbacks[:] = [func for sids, func in run_on_commit]
569+
if execute:
570+
for callback in callbacks:
571+
callback()
572+
573+
else:
574+
with TestCase.captureOnCommitCallbacks(using=using, execute=execute) as callbacks:
575+
yield callbacks
576+
577+
578+
@pytest.fixture(scope="function")
579+
def django_capture_on_commit_callbacks():
580+
return _capture_on_commit_callbacks

Diff for: pytest_django/plugin.py

+1
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from .fixtures import django_db_modify_db_settings_parallel_suffix # noqa
2626
from .fixtures import django_db_modify_db_settings_tox_suffix # noqa
2727
from .fixtures import django_db_modify_db_settings_xdist_suffix # noqa
28+
from .fixtures import django_capture_on_commit_callbacks # noqa
2829
from .fixtures import _live_server_helper # noqa
2930
from .fixtures import admin_client # noqa
3031
from .fixtures import admin_user # noqa

Diff for: tests/test_fixtures.py

+64
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,70 @@ def test_queries(django_assert_num_queries):
230230
assert result.ret == 1
231231

232232

233+
@pytest.mark.django_db
234+
def test_django_capture_on_commit_callbacks(django_capture_on_commit_callbacks) -> None:
235+
if not connection.features.supports_transactions:
236+
pytest.skip("transactions required for this test")
237+
238+
scratch = []
239+
with django_capture_on_commit_callbacks() as callbacks:
240+
transaction.on_commit(lambda: scratch.append("one"))
241+
assert len(callbacks) == 1
242+
assert scratch == []
243+
callbacks[0]()
244+
assert scratch == ["one"]
245+
246+
scratch = []
247+
with django_capture_on_commit_callbacks(execute=True) as callbacks:
248+
transaction.on_commit(lambda: scratch.append("two"))
249+
transaction.on_commit(lambda: scratch.append("three"))
250+
assert len(callbacks) == 2
251+
assert scratch == ["two", "three"]
252+
callbacks[0]()
253+
assert scratch == ["two", "three", "two"]
254+
255+
256+
@pytest.mark.django_db(databases=["default", "second"])
257+
def test_django_capture_on_commit_callbacks_multidb(django_capture_on_commit_callbacks) -> None:
258+
if not connection.features.supports_transactions:
259+
pytest.skip("transactions required for this test")
260+
261+
scratch = []
262+
with django_capture_on_commit_callbacks(using="default", execute=True) as callbacks:
263+
transaction.on_commit(lambda: scratch.append("one"))
264+
assert len(callbacks) == 1
265+
assert scratch == ["one"]
266+
267+
scratch = []
268+
with django_capture_on_commit_callbacks(using="second", execute=True) as callbacks:
269+
transaction.on_commit(lambda: scratch.append("two")) # pragma: no cover
270+
assert len(callbacks) == 0
271+
assert scratch == []
272+
273+
scratch = []
274+
with django_capture_on_commit_callbacks(using="default", execute=True) as callbacks:
275+
transaction.on_commit(lambda: scratch.append("ten"))
276+
transaction.on_commit(lambda: scratch.append("twenty"), using="second") # pragma: no cover
277+
transaction.on_commit(lambda: scratch.append("thirty"))
278+
assert len(callbacks) == 2
279+
assert scratch == ["ten", "thirty"]
280+
281+
282+
@pytest.mark.django_db(transaction=True)
283+
def test_django_capture_on_commit_callbacks_transactional(
284+
django_capture_on_commit_callbacks,
285+
) -> None:
286+
if not connection.features.supports_transactions:
287+
pytest.skip("transactions required for this test")
288+
289+
# Bad usage: no transaction (executes immediately).
290+
scratch = []
291+
with django_capture_on_commit_callbacks() as callbacks:
292+
transaction.on_commit(lambda: scratch.append("one"))
293+
assert len(callbacks) == 0
294+
assert scratch == ["one"]
295+
296+
233297
class TestSettings:
234298
"""Tests for the settings fixture, order matters"""
235299

0 commit comments

Comments
 (0)