django-components/tests/testutils.py
Juro Oravec 5fd45ab424
chore: Push dev to master to release v0.110 (#767)
* feat: skeleton of dependency manager backend (#688)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* refactor: selectolax update and tests cleanup (#702)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* refactor: move release notes to own file (#704)

* chore: merge changes from master (#705)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Yassin Rakha <yaso2go@gmail.com>
Co-authored-by: Emil Stenström <emil@emilstenstrom.se>
fix for nested slots (#698) (#699)

* refactor: remove joint {% component_dependencies %} tag (#706)

Co-authored-by: Emil Stenström <emil@emilstenstrom.se>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* refactor: split up utils file and move utils to util dir (#707)

* docs: Move docs inside src/ to allow imports in python scripts (#708)

* refactor: Docs prep 1 (#715)

* refactor: Document template tags (#716)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* refactor: pass slot fills in template via slots param (#719)

* chore: Merge master to dev (#729)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Yassin Rakha <yaso2go@gmail.com>
Co-authored-by: Emil Stenström <emil@emilstenstrom.se>
Co-authored-by: Tom Larsen <larsent@gmail.com>
fix for nested slots (#698) (#699)

* fix: Do not raise error if multiple slots with same name are flagged as default (#727)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* refactor: tag formatter - allow fwd slash in end tag (#730)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* refactor: Use lowercase names for registry settings (#731)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* docs: add docstrings (#732)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* feat: define settings as a data class for type hints, intellisense, and docs (#733)

* refactor: fix reload-on-change logic, expose autodiscover's dirs-getting logic, rename settings (#734)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* docs: document settings (#743)

* docs: document settings

* refactor: fix linter errors

* feat: passthrough slots and more (#758)

* feat: passthrough slots and more

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* refactor: remove ComponentSlotContext.slots

* refactor: update comment

* docs: update changelog

* refactor: update docstrings

* refactor: document and test-cover more changes

* refactor: revert fill without name

* docs: update README

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* fix: apostrophes in tags (#765)

* refactor: fix merge error - duplicate code

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Emil Stenström <emil@emilstenstrom.se>
2024-11-25 09:41:57 +01:00

210 lines
7.7 KiB
Python

import contextlib
import functools
import sys
from typing import Any, Dict, List, Optional, Tuple, Union
from unittest.mock import Mock, patch
from django.template import Context, Node
from django.template.loader import engines
from django.template.response import TemplateResponse
from django.test import SimpleTestCase, override_settings
from django_components.app_settings import ContextBehavior
from django_components.autodiscovery import autodiscover
from django_components.component_registry import registry
from django_components.middleware import ComponentDependencyMiddleware
# Create middleware instance
response_stash = None
middleware = ComponentDependencyMiddleware(get_response=lambda _: response_stash)
class BaseTestCase(SimpleTestCase):
def setUp(self):
super().setUp()
self._start_gen_id_patch()
def tearDown(self):
self._stop_gen_id_patch()
super().tearDown()
registry.clear()
from django_components.template import _create_template
_create_template.cache_remove() # type: ignore[attr-defined]
# Mock the `generate` function used inside `gen_id` so it returns deterministic IDs
def _start_gen_id_patch(self):
# Random number so that the generated IDs are "hex-looking", e.g. a1bc3d
self._gen_id_count = 10599485
def mock_gen_id(*args, **kwargs):
self._gen_id_count += 1
return hex(self._gen_id_count)[2:]
self._gen_id_patch = patch("django_components.util.misc.generate", side_effect=mock_gen_id)
self._gen_id_patch.start()
def _stop_gen_id_patch(self):
self._gen_id_patch.stop()
self._gen_id_count = 10599485
request = Mock()
mock_template = Mock()
def create_and_process_template_response(template, context=None, use_middleware=True):
context = context if context is not None else Context({})
mock_template.render = lambda context, _: template.render(context)
response = TemplateResponse(request, mock_template, context)
if use_middleware:
response.render()
global response_stash
response_stash = response
response = middleware(request)
else:
response.render()
return response.content.decode("utf-8")
def print_nodes(nodes: List[Node], indent=0) -> None:
"""
Render a Nodelist, inlining child nodes with extra on separate lines and with
extra indentation.
"""
for node in nodes:
child_nodes: List[Node] = []
for attr in node.child_nodelists:
attr_child_nodes = getattr(node, attr, None) or []
if attr_child_nodes:
child_nodes.extend(attr_child_nodes)
repr = str(node)
repr = "\n".join([(" " * 4 * indent) + line for line in repr.split("\n")])
print(repr)
if child_nodes:
print_nodes(child_nodes, indent=indent + 1)
# TODO: Make sure that this is done before/after each test automatically?
@contextlib.contextmanager
def autodiscover_with_cleanup(*args, **kwargs):
"""
Use this in place of regular `autodiscover` in test files to ensure that
the autoimport does not pollute the global state.
"""
imported_modules = autodiscover(*args, **kwargs)
try:
yield imported_modules
finally:
# Teardown - delete autoimported modules, so the module is executed also the
# next time one of the tests calls `autodiscover`.
for mod in imported_modules:
del sys.modules[mod]
ContextBehStr = Union[ContextBehavior, str]
ContextBehParam = Union[ContextBehStr, Tuple[ContextBehStr, Any]]
def parametrize_context_behavior(cases: List[ContextBehParam], settings: Optional[Dict] = None):
"""
Use this decorator to run a test function with django_component's
context_behavior settings set to given values.
You can set only a single mode:
```py
@parametrize_context_behavior(["isolated"])
def test_bla_bla(self):
# do something with app_settings.CONTEXT_BEHAVIOR set
# to "isolated"
...
```
Or you can set a test to run in both modes:
```py
@parametrize_context_behavior(["django", "isolated"])
def test_bla_bla(self):
# Runs this test function twice. Once with
# app_settings.CONTEXT_BEHAVIOR set to "django",
# the other time set to "isolated"
...
```
If you need to pass parametrized data to the tests,
pass a tuple of (mode, data) instead of plain string.
To access the data as a fixture, add `context_behavior_data`
as a function argument:
```py
@parametrize_context_behavior([
("django", "result for django"),
("isolated", "result for isolated"),
])
def test_bla_bla(self, context_behavior_data):
# Runs this test function twice. Once with
# app_settings.CONTEXT_BEHAVIOR set to "django",
# the other time set to "isolated".
#
# `context_behavior_data` will first have a value
# of "result for django", then of "result for isolated"
print(context_behavior_data)
...
```
NOTE: Use only on functions and methods. This decorator was NOT tested on classes
"""
def decorator(test_func):
# NOTE: Ideally this decorator would parametrize the test function
# with `pytest.mark.parametrize`, so all test cases would be treated as separate
# tests and thus isolated. But I wasn't able to get it to work. Hence,
# as a workaround, we run multiple test cases within the same test run.
# Because of this, we need to clear the loader cache, and, on error, we need to
# propagate the info on which test case failed.
@functools.wraps(test_func)
def wrapper(self: BaseTestCase, *args, **kwargs):
for case in cases:
# Clear loader cache, see https://stackoverflow.com/a/77531127/9788634
for engine in engines.all():
engine.engine.template_loaders[0].reset()
# Reset gen_id
self._stop_gen_id_patch()
self._start_gen_id_patch()
case_has_data = not isinstance(case, str)
if isinstance(case, str):
context_beh, fixture = case, None
else:
context_beh, fixture = case
# Set `COMPONENTS={"context_behavior": context_beh}`, but do so carefully,
# so we override only that single setting, and so that we operate on copies
# to avoid spilling settings across the test cases
merged_settings = {} if not settings else settings.copy()
if "COMPONENTS" in merged_settings:
merged_settings["COMPONENTS"] = merged_settings["COMPONENTS"].copy()
else:
merged_settings["COMPONENTS"] = {}
merged_settings["COMPONENTS"]["context_behavior"] = context_beh
with override_settings(**merged_settings):
# Call the test function with the fixture as an argument
try:
if case_has_data:
test_func(self, *args, context_behavior_data=fixture, **kwargs)
else:
test_func(self, *args, **kwargs)
except Exception as err:
# Give a hint on which iteration the test failed
raise RuntimeError(
f"An error occured in test function '{test_func.__name__}' with"
f" context_behavior='{context_beh}'. See the original error above."
) from err
return wrapper
return decorator