From fb17ce46a8f71f7c7f6bfef949c3716f333cff65 Mon Sep 17 00:00:00 2001 From: Sander Kooijmans Date: Thu, 26 Sep 2024 09:36:08 +0200 Subject: [PATCH 1/8] Add render_block function This function returns a response that calls render_block_to_string() under the hood to generate the content of the response. (cherry picked from commit 4d9297e24736fcf5a2e09215f3482cbd06c05869) --- README.rst | 51 +++++++++++++++++++++++++++++ render_block/__init__.py | 14 ++++++-- render_block/base.py | 71 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index d9887fe..fb54c0c 100644 --- a/README.rst +++ b/README.rst @@ -116,6 +116,57 @@ There are also two additional errors that can be raised: ``UnsupportedEngine`` Raised if a template backend besides the Django backend is used. +Testing the context used by render_block +======================================== + +If you write tests with the test client, you typically use the test client's +``get()`` and ``post()`` methods to test your view code. The return value of +these methods is a ``Response`` object. It is not the same as the +``HttpResponse`` object returned by your view. It has some additional data, +such as the context that was used while rendering a template. + +The function ``render_block()`` returns a ``BlockOfTemplateResponse`` object, +which has been prepared to make the context available to the response in +tests. However, its ``notify_block_render()`` method must be mocked so that +it sends a specific signal. This signal is handled by the test client to +add the context to the ``Response`` object. + +One way to mock the ``notify_block_render()`` method is to use the following fixture, +which you can put in a ``conftest.py`` in your tests directory: + +.. code-block:: python + + @pytest.fixture(autouse=True) + def _ensure_block_of_template_response_stores_context() -> Iterator[None]: + """ + This fixture ensures that BlockOfTemplateResponse gives a signal about a template + being rendered during tests. This signal makes sure that the context is set to the + response object in the test, allowing assertions to be made using the context. + The TemplateResponse class uses the same signal to get the context added to the + response during tests. Thus, this fixture makes rendering a block of a template be + just as easy to test as rendering a complete template. + """ + mock_method = patch( + "render_block.BlockOfTemplateResponse.notify_block_render" + ).start() + mock_method.side_effect = lambda template, context: template_rendered.send( + sender=None, template=template, context=context + ) + + yield + + mock_method.stop() + +Assuming a view exists that uses ``render_block()`` and you want to test the context that +was passed as parameter to ``render_block()``, you can access the context in your tests, +like this: + +.. code-block:: python + + response = client.get(reverse("logbook:messages_overview")) + assert response.status_code == 200 + assert response.context["messages"] == ["Hello, World!", "How do yo do?"] + Contributing ============ diff --git a/render_block/__init__.py b/render_block/__init__.py index 3fd05b8..2cc6faa 100644 --- a/render_block/__init__.py +++ b/render_block/__init__.py @@ -1,4 +1,14 @@ -from render_block.base import render_block_to_string +from render_block.base import ( + BlockOfTemplateResponse, + render_block, + render_block_to_string, +) from render_block.exceptions import BlockNotFound, UnsupportedEngine -__all__ = ["BlockNotFound", "UnsupportedEngine", "render_block_to_string"] +__all__ = [ + "BlockNotFound", + "BlockOfTemplateResponse", + "UnsupportedEngine", + "render_block", + "render_block_to_string", +] diff --git a/render_block/base.py b/render_block/base.py index e137c99..9cc7658 100644 --- a/render_block/base.py +++ b/render_block/base.py @@ -1,8 +1,9 @@ -from typing import List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union -from django.http import HttpRequest +from django.http import HttpRequest, HttpResponse from django.template import Context, loader from django.template.backends.django import Template as DjangoTemplate +from django.template.response import TemplateResponse try: from django.template.backends.jinja2 import Template as Jinja2Template @@ -27,10 +28,13 @@ def render_block_to_string( Loads the given template_name and renders the given block with the given dictionary as context. Returns a string. - template_name + :param template_name: The name of the template to load and render. If it's a list of template names, Django uses select_template() instead of get_template() to find the template. + :param block_name: The name of the block to load. + :param context: The context dictionary used while rendering the template. + :param request: The request that triggers the rendering of the block. """ # Like render_to_string, template_name can be a string or a list/tuple. @@ -55,3 +59,64 @@ def render_block_to_string( raise UnsupportedEngine( "Can only render blocks from the Django template backend." ) + + +def render_block( + request: HttpRequest, + template_name: str, + block_name: str, + context: Dict[str, Any] | None = None, + status: int | None = None, +) -> HttpResponse: + """ + Loads the given template_name and renders the given block with the given dictionary + as context. Returns a HttpResponseForBlock instance. + + :param request: The request that triggers the rendering of the block. + :param template_name: + The name of the template to load and render. If it's a list of + template names, Django uses select_template() instead of + get_template() to find the template. + :param block_name: The name of the block to load. + :param context: The context dictionary used while rendering the template. + :param status: The status of the response. + """ + return BlockOfTemplateResponse( + request=request, + template_name=template_name, + block_name=block_name, + context=context, + status=status, + ) + + +class BlockOfTemplateResponse(TemplateResponse): + """This class implements a TemplateResponse that only renders a block from the template.""" + + def __init__( + self, + request: HttpRequest, + template_name: str, + block_name: str, + context: dict[str, Any] | None = None, + status: int | None = None, + ): + super().__init__( + request=request, template=template_name, context=context, status=status + ) + self.block_name = block_name + + @property + def rendered_content(self) -> str: + context = self.resolve_context(self.context_data) + self.notify_block_render(self.template_name, context) + return render_block_to_string( + self.template_name, self.block_name, context=context, request=self._request + ) + + def notify_block_render( + self, + template_name: Union[List[str], Tuple[str, ...], DjangoTemplate, str], + context: Union[Dict[str, Any], None], + ) -> None: + pass From f5e6e9899d84fb9e98108b6e3f132ee566be0c50 Mon Sep 17 00:00:00 2001 From: Sander Kooijmans Date: Thu, 26 Sep 2024 16:56:57 +0200 Subject: [PATCH 2/8] Fix line that is too long (cherry picked from commit e46abe5c256b7cfd64189c49cc6f29b33513dd4d) --- render_block/base.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/render_block/base.py b/render_block/base.py index 9cc7658..6e41674 100644 --- a/render_block/base.py +++ b/render_block/base.py @@ -91,7 +91,10 @@ def render_block( class BlockOfTemplateResponse(TemplateResponse): - """This class implements a TemplateResponse that only renders a block from the template.""" + """ + This class implements a TemplateResponse that only renders a block from the + template. + """ def __init__( self, From 90a78c6acb745e02054f5c3774fd9e59967e4ce7 Mon Sep 17 00:00:00 2001 From: Sander Kooijmans Date: Thu, 26 Sep 2024 16:57:57 +0200 Subject: [PATCH 3/8] Add tests for render_block() Because render_block() must be called from view code, the settings have been changed for the tests to include a "tests" app, with a single URL and a single View class. (cherry picked from commit 1e1683a17acc2cec86d2eaa21d282754f999529d) --- tests/settings.py | 3 + tests/tests.py | 152 ++++++++++++++++++++++++++++++++++++++++++++-- tests/urls.py | 7 +++ tests/views.py | 27 ++++++++ 4 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 tests/urls.py create mode 100644 tests/views.py diff --git a/tests/settings.py b/tests/settings.py index 4bb4fda..36394a6 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -29,4 +29,7 @@ EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" INSTALLED_APPS = ("tests",) + AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",) + +ROOT_URLCONF = "tests.urls" diff --git a/tests/tests.py b/tests/tests.py index 1417dfb..40d2280 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -1,13 +1,22 @@ from unittest import skip +from unittest.mock import patch from django.template import Context -from django.test import RequestFactory, TestCase, modify_settings, override_settings +from django.test import ( + Client, + RequestFactory, + TestCase, + modify_settings, + override_settings, +) +from django.test.signals import template_rendered +from django.urls import reverse from render_block import BlockNotFound, UnsupportedEngine, render_block_to_string -class TestDjango(TestCase): - """Test the Django templating engine.""" +class TestRenderBlockToStringForDjango(TestCase): + """Test render_block_to_string for the Django templating engine.""" def assertExceptionMessageEquals(self, exception: Exception, expected: str) -> None: self.assertEqual(expected, exception.args[0]) @@ -175,6 +184,72 @@ def test_request_context(self) -> None: self.assertEqual(result, "/dummy-url") +@modify_settings( + INSTALLED_APPS={ + "prepend": [ + "django.contrib.auth", + "django.contrib.contenttypes", + ], + }, +) +class TestRenderBlockForDjango(TestCase): + """Test render_block for the Django templating engine.""" + + def setUp(self): + self.mock_method = patch( + "render_block.BlockOfTemplateResponse.notify_block_render" + ).start() + self.mock_method.side_effect = lambda template, context: template_rendered.send( + sender=None, template=template, context=context + ) + + def tearDown(self): + self.mock_method.stop() + + def assertExceptionMessageEquals(self, exception: Exception, expected: str) -> None: + self.assertEqual(expected, exception.args[0]) + + def test_block(self) -> None: + client = Client() + data = { + "template_name": "test5.html", + "block_name": "block2", + "foo": "bar", + } + response = client.post(reverse("block"), data=data) + + result = response.content.decode("utf-8") + self.assertEqual(result, "bar") + self.assertEquals(response.context["foo"], "bar") + + @override_settings( + TEMPLATES=[ + { + "BACKEND": "django.template.backends.dummy.TemplateStrings", + "DIRS": ["tests/templates"], + "APP_DIRS": True, + } + ] + ) + def test_different_backend(self) -> None: + """ + Ensure an exception is thrown if a different backed from the Django + backend is used. + """ + client = Client() + data = { + "template_name": "test5.html", + "block_name": "block2", + "foo": "bar", + } + with self.assertRaises(UnsupportedEngine) as exc: + client.post(reverse("block"), data=data) + + self.assertExceptionMessageEquals( + exc.exception, "Can only render blocks from the Django template backend." + ) + + @override_settings( TEMPLATES=[ { @@ -184,8 +259,8 @@ def test_request_context(self) -> None: } ] ) -class TestJinja2(TestCase): - """Test the Django templating engine.""" +class TestRenderBlockToStringForJinja2(TestCase): + """Test render_block_to_string for the Jinja2 templating engine.""" def assertExceptionMessageEquals(self, exception: Exception, expected: str) -> None: self.assertEqual(expected, exception.args[0]) @@ -271,3 +346,70 @@ def test_context(self) -> None: data = "block2 from test5" result = render_block_to_string("test5.html", "block2", {"foo": data}) self.assertEqual(result, data) + + +@override_settings( + TEMPLATES=[ + { + "BACKEND": "django.template.backends.jinja2.Jinja2", + "DIRS": ["tests/templates"], + "APP_DIRS": True, + } + ] +) +class TestRenderBlockForJinja2(TestCase): + """Test render_block for the Jinja2 templating engine.""" + + def setUp(self): + self.mock_method = patch( + "render_block.BlockOfTemplateResponse.notify_block_render" + ).start() + self.mock_method.side_effect = lambda template, context: template_rendered.send( + sender=None, template=template, context=context + ) + + def tearDown(self): + self.mock_method.stop() + + def assertExceptionMessageEquals(self, exception: Exception, expected: str) -> None: + self.assertEqual(expected, exception.args[0]) + + def test_block(self) -> None: + client = Client() + data = { + "template_name": "test5.html", + "block_name": "block2", + "foo": "bar", + } + response = client.post(reverse("block"), data=data) + + result = response.content.decode("utf-8") + self.assertEqual(result, "bar") + self.assertEquals(response.context["foo"], "bar") + + @override_settings( + TEMPLATES=[ + { + "BACKEND": "django.template.backends.dummy.TemplateStrings", + "DIRS": ["tests/templates"], + "APP_DIRS": True, + } + ] + ) + def test_different_backend(self) -> None: + """ + Ensure an exception is thrown if a different backed from the Django + backend is used. + """ + client = Client() + data = { + "template_name": "test5.html", + "block_name": "block2", + "foo": "bar", + } + with self.assertRaises(UnsupportedEngine) as exc: + client.post(reverse("block"), data=data) + + self.assertExceptionMessageEquals( + exc.exception, "Can only render blocks from the Django template backend." + ) diff --git a/tests/urls.py b/tests/urls.py new file mode 100644 index 0000000..9824308 --- /dev/null +++ b/tests/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from tests import views + +urlpatterns = [ + path("block", views.BlockView.as_view(), name="block"), +] diff --git a/tests/views.py b/tests/views.py new file mode 100644 index 0000000..da4046a --- /dev/null +++ b/tests/views.py @@ -0,0 +1,27 @@ +from django.http import HttpResponse +from django.views import View + +from render_block import render_block + + +class BlockView(View): + """ + This view simply calls render_block with parameters from the data of the request. + """ + + def post(self, request) -> HttpResponse: + context = { + key: request.POST.get(key) + for key in request.POST.keys() + if key not in ("template_name", "block_name") + } + if context == {}: + context = None + + return render_block( + request, + template_name=request.POST.get("template_name"), + block_name=request.POST.get("block_name"), + context=context, + status=request.POST.get("status"), + ) From f7597a26f59d227b61dc21fc2c0f60f7cb55eb4b Mon Sep 17 00:00:00 2001 From: Sander Kooijmans Date: Thu, 26 Sep 2024 16:58:50 +0200 Subject: [PATCH 4/8] Replace pytext fixture by setup() and tearDown() This is more in sync with the rest of the project and is a bit easier to understand. (cherry picked from commit 97819c43774e9ec45825505d701cc7b9ca749a7f) --- README.rst | 41 ++++++++++++++++++----------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/README.rst b/README.rst index fb54c0c..fdbb7b9 100644 --- a/README.rst +++ b/README.rst @@ -131,31 +131,26 @@ tests. However, its ``notify_block_render()`` method must be mocked so that it sends a specific signal. This signal is handled by the test client to add the context to the ``Response`` object. -One way to mock the ``notify_block_render()`` method is to use the following fixture, -which you can put in a ``conftest.py`` in your tests directory: +One way to mock the ``notify_block_render()`` method is to use the following +setup and tear-down code in your test classes: .. code-block:: python - @pytest.fixture(autouse=True) - def _ensure_block_of_template_response_stores_context() -> Iterator[None]: - """ - This fixture ensures that BlockOfTemplateResponse gives a signal about a template - being rendered during tests. This signal makes sure that the context is set to the - response object in the test, allowing assertions to be made using the context. - The TemplateResponse class uses the same signal to get the context added to the - response during tests. Thus, this fixture makes rendering a block of a template be - just as easy to test as rendering a complete template. - """ - mock_method = patch( - "render_block.BlockOfTemplateResponse.notify_block_render" - ).start() - mock_method.side_effect = lambda template, context: template_rendered.send( - sender=None, template=template, context=context - ) - - yield - - mock_method.stop() + from unittest.mock import patch + from django.test.signals import template_rendered + + class TestYourCode(TestCase): + + def setUp(self): + self.mock_method = patch( + "render_block.BlockOfTemplateResponse.notify_block_render" + ).start() + self.mock_method.side_effect = lambda template, context: template_rendered.send( + sender=None, template=template, context=context + ) + + def tearDown(self): + self.mock_method.stop() Assuming a view exists that uses ``render_block()`` and you want to test the context that was passed as parameter to ``render_block()``, you can access the context in your tests, @@ -165,7 +160,7 @@ like this: response = client.get(reverse("logbook:messages_overview")) assert response.status_code == 200 - assert response.context["messages"] == ["Hello, World!", "How do yo do?"] + assert response.context["messages"] == ["Disk is full.", "Uninstalled unused apps."] Contributing ============ From f886eeac12b1ba4bfef8919f484c89b64d5777d0 Mon Sep 17 00:00:00 2001 From: Sander Kooijmans Date: Thu, 26 Sep 2024 09:36:08 +0200 Subject: [PATCH 5/8] Use assertEqual instead of assertEquals --- tests/tests.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/tests.py b/tests/tests.py index 40d2280..656b634 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -220,7 +220,7 @@ def test_block(self) -> None: result = response.content.decode("utf-8") self.assertEqual(result, "bar") - self.assertEquals(response.context["foo"], "bar") + self.assertEqual(response.context["foo"], "bar") @override_settings( TEMPLATES=[ @@ -385,7 +385,7 @@ def test_block(self) -> None: result = response.content.decode("utf-8") self.assertEqual(result, "bar") - self.assertEquals(response.context["foo"], "bar") + self.assertEqual(response.context["foo"], "bar") @override_settings( TEMPLATES=[ From 68c1bf8c68384a1f30031d1e9e80195a3dfcad8d Mon Sep 17 00:00:00 2001 From: Sander Kooijmans Date: Mon, 30 Sep 2024 08:18:30 +0200 Subject: [PATCH 6/8] Fix type annotations --- tests/tests.py | 8 ++++---- tests/views.py | 8 +++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/tests.py b/tests/tests.py index 656b634..477472e 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -195,7 +195,7 @@ def test_request_context(self) -> None: class TestRenderBlockForDjango(TestCase): """Test render_block for the Django templating engine.""" - def setUp(self): + def setUp(self) -> None: self.mock_method = patch( "render_block.BlockOfTemplateResponse.notify_block_render" ).start() @@ -203,7 +203,7 @@ def setUp(self): sender=None, template=template, context=context ) - def tearDown(self): + def tearDown(self) -> None: self.mock_method.stop() def assertExceptionMessageEquals(self, exception: Exception, expected: str) -> None: @@ -360,7 +360,7 @@ def test_context(self) -> None: class TestRenderBlockForJinja2(TestCase): """Test render_block for the Jinja2 templating engine.""" - def setUp(self): + def setUp(self) -> None: self.mock_method = patch( "render_block.BlockOfTemplateResponse.notify_block_render" ).start() @@ -368,7 +368,7 @@ def setUp(self): sender=None, template=template, context=context ) - def tearDown(self): + def tearDown(self) -> None: self.mock_method.stop() def assertExceptionMessageEquals(self, exception: Exception, expected: str) -> None: diff --git a/tests/views.py b/tests/views.py index da4046a..2378e46 100644 --- a/tests/views.py +++ b/tests/views.py @@ -1,4 +1,6 @@ -from django.http import HttpResponse +from typing import Dict, Optional, Any + +from django.http import HttpResponse, HttpRequest from django.views import View from render_block import render_block @@ -9,8 +11,8 @@ class BlockView(View): This view simply calls render_block with parameters from the data of the request. """ - def post(self, request) -> HttpResponse: - context = { + def post(self, request: HttpRequest) -> HttpResponse: + context: Optional[Dict[str, Any]] = { key: request.POST.get(key) for key in request.POST.keys() if key not in ("template_name", "block_name") From e42cb4c2e91831b1a2d6c8dcf08df5f36ba938c8 Mon Sep 17 00:00:00 2001 From: Sander Kooijmans Date: Mon, 30 Sep 2024 08:20:46 +0200 Subject: [PATCH 7/8] Fix order of imports --- tests/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/views.py b/tests/views.py index 2378e46..8a1a7c7 100644 --- a/tests/views.py +++ b/tests/views.py @@ -1,6 +1,6 @@ -from typing import Dict, Optional, Any +from typing import Any, Dict, Optional -from django.http import HttpResponse, HttpRequest +from django.http import HttpRequest, HttpResponse from django.views import View from render_block import render_block From 0fe791de3494ab5a01fc8cc7ab479d90df690fff Mon Sep 17 00:00:00 2001 From: Sander Kooijmans Date: Mon, 30 Sep 2024 08:32:38 +0200 Subject: [PATCH 8/8] Use type annotation syntax of Python 3.8 --- render_block/base.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/render_block/base.py b/render_block/base.py index 6e41674..ee016dc 100644 --- a/render_block/base.py +++ b/render_block/base.py @@ -65,8 +65,8 @@ def render_block( request: HttpRequest, template_name: str, block_name: str, - context: Dict[str, Any] | None = None, - status: int | None = None, + context: Optional[Dict[str, Any]] = None, + status: Optional[int] = None, ) -> HttpResponse: """ Loads the given template_name and renders the given block with the given dictionary @@ -101,8 +101,8 @@ def __init__( request: HttpRequest, template_name: str, block_name: str, - context: dict[str, Any] | None = None, - status: int | None = None, + context: Optional[Dict[str, Any]] = None, + status: Optional[int] = None, ): super().__init__( request=request, template=template_name, context=context, status=status