feat: add decorator for writing component tests (#1008)

* feat: add decorator for writing component tests

* refactor: udpate changelog + update deps pins

* refactor: fix deps

* refactor: make cached_ref into generic and fix linter errors

* refactor: fix coverage testing

* refactor: use global var instead of env var for is_testing state
This commit is contained in:
Juro Oravec 2025-03-02 19:46:12 +01:00 committed by GitHub
parent 81ac59f7fb
commit 7dfcb447c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 4428 additions and 3661 deletions

View file

@ -5,7 +5,9 @@ from os import PathLike
from pathlib import Path
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
Generic,
List,
Literal,
@ -671,94 +673,140 @@ defaults = ComponentsSettings(
# fmt: on
# Interface through which we access the settings.
#
# This is the only place where we actually access the settings.
# The settings are merged with defaults, and then validated.
#
# The settings are then available through the `app_settings` object.
#
# Settings are loaded from Django settings only once, at `apps.py` in `ready()`.
class InternalSettings:
@property
def _settings(self) -> ComponentsSettings:
def __init__(self, settings: Optional[Dict[str, Any]] = None):
self._settings = ComponentsSettings(**settings) if settings else defaults
def _load_settings(self) -> None:
data = getattr(settings, "COMPONENTS", {})
return ComponentsSettings(**data) if not isinstance(data, ComponentsSettings) else data
components_settings = ComponentsSettings(**data) if not isinstance(data, ComponentsSettings) else data
@property
def AUTODISCOVER(self) -> bool:
return default(self._settings.autodiscover, cast(bool, defaults.autodiscover))
# Merge we defaults and otherwise initialize if necessary
@property
def CACHE(self) -> Optional[str]:
return default(self._settings.cache, defaults.cache)
# For DIRS setting, we use a getter for the default value, because the default value
# uses Django settings, which may not yet be initialized at the time these settings are generated.
dirs_default_fn = cast(Dynamic[Sequence[Union[str, Tuple[str, str]]]], defaults.dirs)
dirs_default = dirs_default_fn.getter()
@property
def DIRS(self) -> Sequence[Union[str, PathLike, Tuple[str, str], Tuple[str, PathLike]]]:
# For DIRS we use a getter, because default values uses Django settings,
# which may not yet be initialized at the time these settings are generated.
default_fn = cast(Dynamic[Sequence[Union[str, Tuple[str, str]]]], defaults.dirs)
default_dirs = default_fn.getter()
return default(self._settings.dirs, default_dirs)
self._settings = ComponentsSettings(
autodiscover=default(components_settings.autodiscover, defaults.autodiscover),
cache=default(components_settings.cache, defaults.cache),
dirs=default(components_settings.dirs, dirs_default),
app_dirs=default(components_settings.app_dirs, defaults.app_dirs),
debug_highlight_components=default(
components_settings.debug_highlight_components, defaults.debug_highlight_components
),
debug_highlight_slots=default(components_settings.debug_highlight_slots, defaults.debug_highlight_slots),
dynamic_component_name=default(
components_settings.dynamic_component_name, defaults.dynamic_component_name
),
libraries=default(components_settings.libraries, defaults.libraries),
multiline_tags=default(components_settings.multiline_tags, defaults.multiline_tags),
reload_on_file_change=self._prepare_reload_on_file_change(components_settings),
template_cache_size=default(components_settings.template_cache_size, defaults.template_cache_size),
static_files_allowed=default(components_settings.static_files_allowed, defaults.static_files_allowed),
static_files_forbidden=self._prepare_static_files_forbidden(components_settings),
context_behavior=self._prepare_context_behavior(components_settings),
tag_formatter=default(components_settings.tag_formatter, defaults.tag_formatter), # type: ignore[arg-type]
)
@property
def APP_DIRS(self) -> Sequence[str]:
return default(self._settings.app_dirs, cast(List[str], defaults.app_dirs))
@property
def DEBUG_HIGHLIGHT_COMPONENTS(self) -> bool:
return default(self._settings.debug_highlight_components, cast(bool, defaults.debug_highlight_components))
@property
def DEBUG_HIGHLIGHT_SLOTS(self) -> bool:
return default(self._settings.debug_highlight_slots, cast(bool, defaults.debug_highlight_slots))
@property
def DYNAMIC_COMPONENT_NAME(self) -> str:
return default(self._settings.dynamic_component_name, cast(str, defaults.dynamic_component_name))
@property
def LIBRARIES(self) -> List[str]:
return default(self._settings.libraries, cast(List[str], defaults.libraries))
@property
def MULTILINE_TAGS(self) -> bool:
return default(self._settings.multiline_tags, cast(bool, defaults.multiline_tags))
@property
def RELOAD_ON_FILE_CHANGE(self) -> bool:
val = self._settings.reload_on_file_change
def _prepare_reload_on_file_change(self, new_settings: ComponentsSettings) -> bool:
val = new_settings.reload_on_file_change
# TODO_REMOVE_IN_V1
if val is None:
val = self._settings.reload_on_template_change
val = new_settings.reload_on_template_change
return default(val, cast(bool, defaults.reload_on_file_change))
@property
def TEMPLATE_CACHE_SIZE(self) -> int:
return default(self._settings.template_cache_size, cast(int, defaults.template_cache_size))
@property
def STATIC_FILES_ALLOWED(self) -> Sequence[Union[str, re.Pattern]]:
return default(self._settings.static_files_allowed, cast(List[str], defaults.static_files_allowed))
@property
def STATIC_FILES_FORBIDDEN(self) -> Sequence[Union[str, re.Pattern]]:
val = self._settings.static_files_forbidden
def _prepare_static_files_forbidden(self, new_settings: ComponentsSettings) -> List[Union[str, re.Pattern]]:
val = new_settings.static_files_forbidden
# TODO_REMOVE_IN_V1
if val is None:
val = self._settings.forbidden_static_files
val = new_settings.forbidden_static_files
return default(val, cast(List[str], defaults.static_files_forbidden))
return default(val, cast(List[Union[str, re.Pattern]], defaults.static_files_forbidden))
@property
def CONTEXT_BEHAVIOR(self) -> ContextBehavior:
raw_value = cast(str, default(self._settings.context_behavior, defaults.context_behavior))
return self._validate_context_behavior(raw_value)
def _validate_context_behavior(self, raw_value: Union[ContextBehavior, str]) -> ContextBehavior:
def _prepare_context_behavior(self, new_settings: ComponentsSettings) -> Literal["django", "isolated"]:
raw_value = cast(
Literal["django", "isolated"],
default(new_settings.context_behavior, defaults.context_behavior),
)
try:
return ContextBehavior(raw_value)
ContextBehavior(raw_value)
except ValueError:
valid_values = [behavior.value for behavior in ContextBehavior]
raise ValueError(f"Invalid context behavior: {raw_value}. Valid options are {valid_values}")
return raw_value
# TODO REMOVE THE PROPERTIES BELOW? THEY NO LONGER SERVE ANY PURPOSE
@property
def AUTODISCOVER(self) -> bool:
return self._settings.autodiscover # type: ignore[return-value]
@property
def CACHE(self) -> Optional[str]:
return self._settings.cache
@property
def DIRS(self) -> Sequence[Union[str, PathLike, Tuple[str, str], Tuple[str, PathLike]]]:
return self._settings.dirs # type: ignore[return-value]
@property
def APP_DIRS(self) -> Sequence[str]:
return self._settings.app_dirs # type: ignore[return-value]
@property
def DEBUG_HIGHLIGHT_COMPONENTS(self) -> bool:
return self._settings.debug_highlight_components # type: ignore[return-value]
@property
def DEBUG_HIGHLIGHT_SLOTS(self) -> bool:
return self._settings.debug_highlight_slots # type: ignore[return-value]
@property
def DYNAMIC_COMPONENT_NAME(self) -> str:
return self._settings.dynamic_component_name # type: ignore[return-value]
@property
def LIBRARIES(self) -> List[str]:
return self._settings.libraries # type: ignore[return-value]
@property
def MULTILINE_TAGS(self) -> bool:
return self._settings.multiline_tags # type: ignore[return-value]
@property
def RELOAD_ON_FILE_CHANGE(self) -> bool:
return self._settings.reload_on_file_change # type: ignore[return-value]
@property
def TEMPLATE_CACHE_SIZE(self) -> int:
return self._settings.template_cache_size # type: ignore[return-value]
@property
def STATIC_FILES_ALLOWED(self) -> Sequence[Union[str, re.Pattern]]:
return self._settings.static_files_allowed # type: ignore[return-value]
@property
def STATIC_FILES_FORBIDDEN(self) -> Sequence[Union[str, re.Pattern]]:
return self._settings.static_files_forbidden # type: ignore[return-value]
@property
def CONTEXT_BEHAVIOR(self) -> ContextBehavior:
return ContextBehavior(self._settings.context_behavior)
@property
def TAG_FORMATTER(self) -> Union["TagFormatterABC", str]:
tag_formatter = default(self._settings.tag_formatter, cast(str, defaults.tag_formatter))
return cast(Union["TagFormatterABC", str], tag_formatter)
return self._settings.tag_formatter # type: ignore[return-value]
app_settings = InternalSettings()

View file

@ -19,6 +19,8 @@ class ComponentsConfig(AppConfig):
from django_components.components.dynamic import DynamicComponent
from django_components.util.django_monkeypatch import monkeypatch_template_cls
app_settings._load_settings()
# NOTE: This monkeypatch is applied here, before Django processes any requests.
# To make django-components work with django-debug-toolbar-template-profiler
# See https://github.com/django-components/django-components/discussions/819

View file

@ -3,6 +3,12 @@ from typing import Callable, List, Optional
from django_components.util.loader import get_component_files
from django_components.util.logger import logger
from django_components.util.testing import is_testing
# In tests, we want to capture which modules have been loaded, so we can
# clean them up between tests. But there's no need to track this in
# production.
LOADED_MODULES: List[str] = []
def autodiscover(
@ -94,4 +100,10 @@ def _import_modules(
logger.debug(f'Importing module "{module_name}"')
importlib.import_module(module_name)
imported_modules.append(module_name)
# In tests tagged with `@djc_test`, we want to capture the modules that
# are imported so we can clean them up between tests.
if is_testing():
LOADED_MODULES.append(module_name)
return imported_modules

View file

@ -1,3 +1,4 @@
import sys
import types
from collections import deque
from contextlib import contextmanager
@ -22,6 +23,7 @@ from typing import (
Union,
cast,
)
from weakref import ReferenceType
from django.core.exceptions import ImproperlyConfigured
from django.forms.widgets import Media as MediaCls
@ -77,6 +79,7 @@ from django_components.util.logger import trace_component_msg
from django_components.util.misc import gen_id, get_import_path, hash_comp_cls
from django_components.util.template_tag import TagAttr
from django_components.util.validation import validate_typed_dict, validate_typed_tuple
from django_components.util.weakref import cached_ref
# TODO_REMOVE_IN_V1 - Users should use top-level import instead
# isort: off
@ -99,6 +102,17 @@ JsDataType = TypeVar("JsDataType", bound=Mapping[str, Any])
CssDataType = TypeVar("CssDataType", bound=Mapping[str, Any])
# NOTE: `ReferenceType` is NOT a generic pre-3.9
if sys.version_info >= (3, 9):
AllComponents = List[ReferenceType["ComponentRegistry"]]
else:
AllComponents = List[ReferenceType]
# Keep track of all the Component classes created, so we can clean up after tests
ALL_COMPONENTS: AllComponents = []
@dataclass(frozen=True)
class RenderInput(Generic[ArgsType, KwargsType, SlotsType]):
context: Context
@ -613,6 +627,8 @@ class Component(
cls._class_hash = hash_comp_cls(cls)
comp_hash_mapping[cls._class_hash] = cls
ALL_COMPONENTS.append(cached_ref(cls)) # type: ignore[arg-type]
@contextmanager
def _with_metadata(self, item: MetadataItem) -> Generator[None, None, None]:
self._metadata_stack.append(item)

View file

@ -1,6 +1,7 @@
import os
import sys
from collections import deque
from copy import copy
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Protocol, Tuple, Type, Union, cast
@ -238,6 +239,13 @@ class ComponentMedia:
f"Received non-null value from both '{inlined_attr}' and '{file_attr}' in"
f" Component {self.comp_cls.__name__}. Only one of the two must be set."
)
# Make a copy of the original state, so we can reset it in tests
self._original = copy(self)
# Return ComponentMedia to its original state before the media was resolved
def reset(self) -> None:
self.__dict__.update(self._original.__dict__)
self.resolved = False
# This metaclass is all about one thing - lazily resolving the media files.

View file

@ -1,4 +1,6 @@
import sys
from typing import TYPE_CHECKING, Callable, Dict, List, NamedTuple, Optional, Set, Type, Union
from weakref import ReferenceType, finalize
from django.template import Library
from django.template.base import Parser, Token
@ -6,6 +8,7 @@ from django.template.base import Parser, Token
from django_components.app_settings import ContextBehaviorType, app_settings
from django_components.library import is_tag_protected, mark_protected_tags, register_tag
from django_components.tag_formatter import TagFormatterABC, get_tag_formatter
from django_components.util.weakref import cached_ref
if TYPE_CHECKING:
from django_components.component import (
@ -19,6 +22,13 @@ if TYPE_CHECKING:
)
# NOTE: `ReferenceType` is NOT a generic pre-3.9
if sys.version_info >= (3, 9):
AllRegistries = List[ReferenceType["ComponentRegistry"]]
else:
AllRegistries = List[ReferenceType]
class AlreadyRegistered(Exception):
"""
Raised when you try to register a [Component](../api#django_components#Component),
@ -130,7 +140,7 @@ class InternalRegistrySettings(NamedTuple):
# We keep track of all registries that exist so that, when users want to
# dynamically resolve component name to component class, they would be able
# to search across all registries.
all_registries: List["ComponentRegistry"] = []
ALL_REGISTRIES: AllRegistries = []
class ComponentRegistry:
@ -223,10 +233,19 @@ class ComponentRegistry:
self._registry: Dict[str, ComponentRegistryEntry] = {} # component name -> component_entry mapping
self._tags: Dict[str, Set[str]] = {} # tag -> list[component names]
self._library = library
self._settings_input = settings
self._settings: Optional[Callable[[], InternalRegistrySettings]] = None
self._settings = settings
all_registries.append(self)
ALL_REGISTRIES.append(cached_ref(self))
def __del__(self) -> None:
# Unregister all components when the registry is deleted
self.clear()
def __copy__(self) -> "ComponentRegistry":
new_registry = ComponentRegistry(self.library, self._settings)
new_registry._registry = self._registry.copy()
new_registry._tags = self._tags.copy()
return new_registry
@property
def library(self) -> Library:
@ -254,40 +273,24 @@ class ComponentRegistry:
"""
[Registry settings](../api#django_components.RegistrySettings) configured for this registry.
"""
# This is run on subsequent calls
if self._settings is not None:
# NOTE: Registry's settings can be a function, so we always take
# the latest value from Django's settings.
settings = self._settings()
# First-time initialization
# NOTE: We allow the settings to be given as a getter function
# so the settings can respond to changes.
# So we wrapp that in our getter, which assigns default values from the settings.
if callable(self._settings):
settings_input: Optional[RegistrySettings] = self._settings(self)
else:
settings_input = self._settings
def get_settings() -> InternalRegistrySettings:
if callable(self._settings_input):
settings_input: Optional[RegistrySettings] = self._settings_input(self)
else:
settings_input = self._settings_input
if settings_input:
context_behavior = settings_input.context_behavior or settings_input.CONTEXT_BEHAVIOR
tag_formatter = settings_input.tag_formatter or settings_input.TAG_FORMATTER
else:
context_behavior = None
tag_formatter = None
if settings_input:
context_behavior = settings_input.context_behavior or settings_input.CONTEXT_BEHAVIOR
tag_formatter = settings_input.tag_formatter or settings_input.TAG_FORMATTER
else:
context_behavior = None
tag_formatter = None
return InternalRegistrySettings(
context_behavior=context_behavior or app_settings.CONTEXT_BEHAVIOR.value,
tag_formatter=tag_formatter or app_settings.TAG_FORMATTER,
)
self._settings = get_settings
settings = self._settings()
return settings
return InternalRegistrySettings(
context_behavior=context_behavior or app_settings.CONTEXT_BEHAVIOR.value,
tag_formatter=tag_formatter or app_settings.TAG_FORMATTER,
)
def register(self, name: str, component: Type["Component"]) -> None:
"""
@ -330,6 +333,9 @@ class ComponentRegistry:
self._registry[name] = entry
# If the component class is deleted, unregister it from this registry.
finalize(entry.cls, lambda: self.unregister(name) if name in self._registry else None)
def unregister(self, name: str) -> None:
"""
Unregister the [`Component`](../api#django_components.Component) class
@ -360,22 +366,27 @@ class ComponentRegistry:
entry = self._registry[name]
tag = entry.tag
# Unregister the tag from library if this was the last component using this tag
# Unlink component from tag
self._tags[tag].remove(name)
# Unregister the tag from library.
# If this was the last component using this tag, unlink component from tag.
if tag in self._tags:
if name in self._tags[tag]:
self._tags[tag].remove(name)
# Cleanup
is_tag_empty = not len(self._tags[tag])
if is_tag_empty:
del self._tags[tag]
# Cleanup
is_tag_empty = not len(self._tags[tag])
if is_tag_empty:
self._tags.pop(tag, None)
else:
is_tag_empty = True
# Only unregister a tag if it's NOT protected
is_protected = is_tag_protected(self.library, tag)
if not is_protected:
# Unregister the tag from library if this was the last component using this tag
if is_tag_empty and tag in self.library.tags:
del self.library.tags[tag]
self.library.tags.pop(tag, None)
entry = self._registry[name]
del self._registry[name]
def get(self, name: str) -> Type["Component"]:

View file

@ -4,7 +4,7 @@ from typing import Any, Dict, Optional, Type, Union, cast
from django.template import Context, Template
from django_components import Component, ComponentRegistry, NotRegistered, types
from django_components.component_registry import all_registries
from django_components.component_registry import ALL_REGISTRIES
class DynamicComponent(Component):
@ -170,7 +170,11 @@ class DynamicComponent(Component):
component_cls = registry.get(comp_name_or_class)
else:
# Search all registries for the first match
for reg in all_registries:
for reg_ref in ALL_REGISTRIES:
reg = reg_ref()
if not reg:
continue
try:
component_cls = reg.get(comp_name_or_class)
break

View file

@ -106,9 +106,16 @@ class StringifiedNode(Node):
def is_aggregate_key(key: str) -> bool:
key = key.strip()
# NOTE: If we get a key that starts with `:`, like `:class`, we do not split it.
# This syntax is used by Vue and AlpineJS.
return ":" in key and not key.startswith(":")
return (
":" in key
# `:` or `:class` is NOT ok
and not key.startswith(":")
# `attrs:class` is OK, but `attrs:` is NOT ok
and bool(key.split(":", maxsplit=1)[1])
)
# A string that must start and end with quotes, and somewhere inside includes

View file

@ -0,0 +1,9 @@
# Public API for test-related functionality
# isort: off
from django_components.util.testing import djc_test
# isort: on
__all__ = [
"djc_test",
]

View file

@ -0,0 +1,525 @@
import inspect
import sys
from functools import wraps
from typing import Any, Callable, Dict, Iterable, List, Optional, Sequence, Set, Tuple, Type, Union
from unittest.mock import patch
from weakref import ReferenceType
from django.conf import settings as _django_settings
from django.template import engines
from django.test import override_settings
from django_components.component import ALL_COMPONENTS, Component, component_node_subclasses_by_name
from django_components.component_media import ComponentMedia
from django_components.component_registry import ALL_REGISTRIES, ComponentRegistry
# NOTE: `ReferenceType` is NOT a generic pre-3.9
if sys.version_info >= (3, 9):
RegistryRef = ReferenceType[ComponentRegistry]
RegistriesCopies = List[Tuple[ReferenceType[ComponentRegistry], List[str]]]
InitialComponents = List[ReferenceType[Type[Component]]]
else:
RegistriesCopies = List[Tuple[ReferenceType, List[str]]]
InitialComponents = List[ReferenceType]
RegistryRef = ReferenceType
# Whether we're inside a test that was wrapped with `djc_test`.
# This is used so that we capture which modules we imported only if inside a test.
IS_TESTING = False
def is_testing() -> bool:
return IS_TESTING
class GenIdPatcher:
def __init__(self) -> None:
self._gen_id_count = 10599485
self._gen_id_patch: Any = None
# Mock the `generate` function used inside `gen_id` so it returns deterministic IDs
def start(self) -> None:
# Random number so that the generated IDs are "hex-looking", e.g. a1bc3d
self._gen_id_count = 10599485
def mock_gen_id(*args: Any, **kwargs: Any) -> str:
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(self) -> None:
if not self._gen_id_patch:
return
self._gen_id_patch.stop()
self._gen_id_patch = None
class CsrfTokenPatcher:
def __init__(self) -> None:
self._csrf_token = "predictabletoken"
self._csrf_token_patch: Any = None
def start(self) -> None:
self._csrf_token_patch = patch("django.middleware.csrf.get_token", return_value=self._csrf_token)
self._csrf_token_patch.start()
def stop(self) -> None:
if self._csrf_token_patch:
self._csrf_token_patch.stop()
self._csrf_token_patch = None
def djc_test(
django_settings: Union[Optional[Dict], Callable, Type] = None,
components_settings: Optional[Dict] = None,
# Input to `@pytest.mark.parametrize`
parametrize: Optional[
Union[
Tuple[
# names
Sequence[str],
# values
Sequence[Sequence[Any]],
],
Tuple[
# names
Sequence[str],
# values
Sequence[Sequence[Any]],
# ids
Optional[
Union[
Iterable[Union[None, str, float, int, bool]],
Callable[[Any], Optional[object]],
]
],
],
]
] = None,
) -> Callable:
"""
Decorator for testing components from django-components.
`@djc_test` manages the global state of django-components, ensuring that each test is properly
isolated and that components registered in one test do not affect other tests.
This decorator can be applied to a function, method, or a class. If applied to a class,
it will search for all methods that start with `test_`, and apply the decorator to them.
This is applied recursively to nested classes as well.
Examples:
Applying to a function:
```python
from django_components.testing import djc_test
@djc_test
def test_my_component():
@register("my_component")
class MyComponent(Component):
template = "..."
...
```
Applying to a class:
```python
from django_components.testing import djc_test
@djc_test
class TestMyComponent:
def test_something(self):
...
class Nested:
def test_something_else(self):
...
```
Applying to a class is the same as applying the decorator to each `test_` method individually:
```python
from django_components.testing import djc_test
class TestMyComponent:
@djc_test
def test_something(self):
...
class Nested:
@djc_test
def test_something_else(self):
...
```
To use `@djc_test`, Django must be set up first:
```python
import django
from django_components.testing import djc_test
django.setup()
@djc_test
def test_my_component():
...
```
**Arguments:**
- `django_settings`: Django settings, a dictionary passed to Django's
[`@override_settings`](https://docs.djangoproject.com/en/5.1/topics/testing/tools/#django.test.override_settings).
The test runs within the context of these overridden settings.
If `django_settings` contains django-components settings (`COMPONENTS` field), these are merged.
Other Django settings are simply overridden.
- `components_settings`: Instead of defining django-components settings under `django_settings["COMPONENTS"]`,
you can simply set the Components settings here.
These settings are merged with the django-components settings from `django_settings["COMPONENTS"]`.
Fields in `components_settings` override fields in `django_settings["COMPONENTS"]`.
- `parametrize`: Parametrize the test function with
[`pytest.mark.parametrize`](https://docs.pytest.org/en/stable/how-to/parametrize.html#pytest-mark-parametrize).
This requires [pytest](https://docs.pytest.org/) to be installed.
The input is a tuple of:
- `(param_names, param_values)` or
- `(param_names, param_values, ids)`
Example:
```py
from django_components.testing import djc_test
@djc_test(
parametrize=(
["input", "expected"],
[[1, "<div>1</div>"], [2, "<div>2</div>"]],
ids=["1", "2"]
)
)
def test_component(input, expected):
rendered = MyComponent(input=input).render()
assert rendered == expected
```
You can parametrize the Django or Components settings by setting up parameters called
`django_settings` and `components_settings`. These will be merged with the respetive settings
from the decorator.
Example of parametrizing context_behavior:
```python
from django_components.testing import djc_test
@djc_test(
components_settings={
# Settings shared by all tests
"app_dirs": ["custom_dir"],
},
parametrize=(
# Parametrized settings
["components_settings"],
[
[{"context_behavior": "django"}],
[{"context_behavior": "isolated"}],
],
["django", "isolated"],
)
)
def test_context_behavior(components_settings):
rendered = MyComponent().render()
...
```
**Settings resolution:**
`@djc_test` accepts settings from different sources. The settings are resolved in the following order:
- Django settings:
1. The defaults are the Django settings that Django was set up with.
2. Those are then overriden with fields in the `django_settings` kwarg.
3. The parametrized `django_settings` override the fields on the `django_settings` kwarg.
Priority: `django_settings` (parametrized) > `django_settings` > `django.conf.settings`
- Components settings:
1. Same as above, except that the `django_settings["COMPONENTS"]` field is merged instead of overridden.
2. The `components_settings` kwarg is then merged with the `django_settings["COMPONENTS"]` field.
3. The parametrized `components_settings` override the fields on the `components_settings` kwarg.
Priority: `components_settings` (parametrized) > `components_settings` > `django_settings["COMPONENTS"]` > `django.conf.settings.COMPONENTS`
""" # noqa: E501
def decorator(func: Callable) -> Callable:
if isinstance(func, type):
# If `djc_test` is applied to a class, we need to apply it to each test method
# individually.
# The rest of this function addresses `func` being a function
decorator = djc_test(
# Check for callable in case `@djc_test` was applied without calling it as `djc_test(settings)`.
django_settings=django_settings if not callable(django_settings) else None,
components_settings=components_settings,
parametrize=parametrize,
)
for name, attr in func.__dict__.items():
if isinstance(attr, type):
# If the attribute is a class, apply the decorator to its methods:
djc_test(
django_settings=django_settings if not callable(django_settings) else None,
components_settings=components_settings,
parametrize=parametrize,
)(attr)
if callable(attr) and name.startswith("test_"):
method = decorator(attr)
setattr(func, name, method)
return func
if getattr(func, "_djc_test_wrapped", False):
return func
gen_id_patcher = GenIdPatcher()
csrf_token_patcher = CsrfTokenPatcher()
# Contents of this function will run as the test
def _wrapper_impl(*args: Any, **kwargs: Any) -> Any:
# Merge the settings
current_django_settings = django_settings if not callable(django_settings) else None
current_django_settings = current_django_settings.copy() if current_django_settings else {}
if parametrize and "django_settings" in kwargs:
current_django_settings.update(kwargs["django_settings"])
current_component_settings = components_settings.copy() if components_settings else {}
if parametrize and "components_settings" in kwargs:
# We've received a parametrized test function, so we need to
# apply the parametrized settings to the test function.
current_component_settings.update(kwargs["components_settings"])
merged_settings = _merge_django_settings(
current_django_settings,
current_component_settings,
)
with override_settings(**merged_settings):
# Make a copy of `ALL_COMPONENTS` and `ALL_REGISTRIES` as they were before the test.
# Since the tests require Django to be configured, this should contain any
# components that were registered with autodiscovery / at `AppConfig.ready()`.
_ALL_COMPONENTS = ALL_COMPONENTS.copy()
_ALL_REGISTRIES_COPIES: RegistriesCopies = []
for reg_ref in ALL_REGISTRIES:
reg = reg_ref()
if not reg:
continue
_ALL_REGISTRIES_COPIES.append((reg_ref, list(reg._registry.keys())))
# Prepare global state
_setup_djc_global_state(gen_id_patcher, csrf_token_patcher)
def cleanup() -> None:
_clear_djc_global_state(
gen_id_patcher,
csrf_token_patcher,
_ALL_COMPONENTS, # type: ignore[arg-type]
_ALL_REGISTRIES_COPIES,
)
try:
# Execute
result = func(*args, **kwargs)
except Exception as err:
# On failure
cleanup()
raise err from None
# On success
cleanup()
return result
# Handle async test functions
if inspect.iscoroutinefunction(func):
async def wrapper_outer(*args: Any, **kwargs: Any) -> Any:
return await _wrapper_impl(*args, **kwargs)
else:
def wrapper_outer(*args: Any, **kwargs: Any) -> Any: # type: ignore[misc]
return _wrapper_impl(*args, **kwargs)
wrapper = wraps(func)(wrapper_outer)
# Allow to parametrize tests with pytest. This will effectively run the test multiple times,
# with the different parameter values, and will display these as separte tests in the report.
if parametrize:
# We optionally allow to pass in the `ids` kwarg. Since `ids` is kwarg-only,
# we can't just spread all tuple elements into the `parametrize` call, but we have
# to manually apply it.
if len(parametrize) == 3:
# @pytest.mark.parametrize("a,b,expected", testdata, ids=["forward", "backward"])
param_names, values, ids = parametrize
else:
# @pytest.mark.parametrize("a,b,expected", testdata)
param_names, values = parametrize
ids = None
for value in values:
# Validate that the first user-provided element in each value tuple is a dictionary,
# since it's meant to be used for overriding django-components settings
value_overrides = value[0]
if not isinstance(value_overrides, dict):
raise ValueError(
"The first element in each value tuple in `parametrize`"
f"must be a dictionary, but got {value_overrides}"
)
# NOTE: Lazily import pytest, so user can still run tests with plain `unittest`
# if they choose not to use parametrization.
import pytest
wrapper = pytest.mark.parametrize(param_names, values, ids=ids)(wrapper)
wrapper._djc_test_wrapped = True # type: ignore[attr-defined]
return wrapper
# Handle `@djc_test` (no arguments, func passed directly)
if callable(django_settings):
return decorator(django_settings)
# Handle `@djc_test(settings)`
return decorator
# Merge settings such that the fields in the `COMPONENTS` setting are merged.
def _merge_django_settings(
django_settings: Optional[Dict] = None,
components_settings: Optional[Dict] = None,
) -> Dict:
merged_settings = {} if not django_settings else django_settings.copy()
merged_settings["COMPONENTS"] = {
# Use the Django settings as they were before the `override_settings`
# as the defaults.
**(_django_settings.COMPONENTS if _django_settings.configured else {}),
**merged_settings.get("COMPONENTS", {}),
**(components_settings or {}),
}
return merged_settings
def _setup_djc_global_state(
gen_id_patcher: GenIdPatcher,
csrf_token_patcher: CsrfTokenPatcher,
) -> None:
# Declare that the code is running in test mode - this is used
# by the import / autodiscover mechanism to clean up loaded modules
# between tests.
global IS_TESTING
IS_TESTING = True
gen_id_patcher.start()
csrf_token_patcher.start()
# Re-load the settings, so that the test-specific settings overrides are applied
from django_components.app_settings import app_settings
app_settings._load_settings()
def _clear_djc_global_state(
gen_id_patcher: GenIdPatcher,
csrf_token_patcher: CsrfTokenPatcher,
initial_components: InitialComponents,
initial_registries_copies: RegistriesCopies,
) -> None:
gen_id_patcher.stop()
csrf_token_patcher.stop()
# Clear loader cache - That is, templates that were loaded with Django's `get_template()`.
# This applies to components that had the `template_name` / `template_field` field set.
# See https://stackoverflow.com/a/77531127/9788634
#
# If we don't do this, then, since the templates are cached, the next test might fail
# beause the IDs count will reset to 0, but we won't generate IDs for the Nodes of the cached
# templates. Thus, the IDs will be out of sync between the tests.
for engine in engines.all():
engine.engine.template_loaders[0].reset()
# NOTE: There are 1-2 tests which check Templates, so we need to clear the cache
from django_components.cache import component_media_cache, template_cache
if template_cache:
template_cache.clear()
if component_media_cache:
component_media_cache.clear()
# Remove cached Node subclasses
component_node_subclasses_by_name.clear()
# Clean up any loaded media (HTML, JS, CSS)
for comp_cls_ref in ALL_COMPONENTS:
comp_cls = comp_cls_ref()
if comp_cls is None:
continue
for file_attr, value_attr in [("template_file", "template"), ("js_file", "js"), ("css_file", "css")]:
# If both fields are set, then the value was set from the file field.
# Since we have some tests that check for these, we need to reset the state.
comp_media: ComponentMedia = comp_cls._component_media # type: ignore[attr-defined]
if getattr(comp_media, file_attr, None) and getattr(comp_media, value_attr, None):
# Remove the value field, so it's not used in the next test
setattr(comp_media, value_attr, None)
comp_media.reset()
# Remove components that were created during the test
initial_components_set = set(initial_components)
all_comps_len = len(ALL_COMPONENTS)
for index in range(all_comps_len):
reverse_index = all_comps_len - index - 1
comp_cls_ref = ALL_COMPONENTS[reverse_index]
if comp_cls_ref not in initial_components_set:
del ALL_COMPONENTS[reverse_index]
# Remove registries that were created during the test
initial_registries_set: Set[RegistryRef] = set([reg_ref for reg_ref, init_keys in initial_registries_copies])
for index in range(len(ALL_REGISTRIES)):
registry_ref = ALL_REGISTRIES[len(ALL_REGISTRIES) - index - 1]
if registry_ref not in initial_registries_set:
del ALL_REGISTRIES[len(ALL_REGISTRIES) - index - 1]
# For the remaining registries, unregistr components that were registered
# during tests.
# NOTE: The approach below does NOT take into account:
# - If a component was UNregistered during the test
# - If a previously-registered component was overwritten with different registration.
for reg_ref, init_keys in initial_registries_copies:
registry_original = reg_ref()
if not registry_original:
continue
# Get the keys that were registered during the test
initial_registered_keys = set(init_keys)
after_test_registered_keys = set(registry_original._registry.keys())
keys_registered_during_test = after_test_registered_keys - initial_registered_keys
# Remove them
for key in keys_registered_during_test:
registry_original.unregister(key)
# Delete autoimported modules from memory, so the module
# is executed also the next time one of the tests calls `autodiscover`.
from django_components.autodiscovery import LOADED_MODULES
for mod in LOADED_MODULES:
sys.modules.pop(mod, None)
LOADED_MODULES.clear()
global IS_TESTING
IS_TESTING = False

View file

@ -0,0 +1,29 @@
import sys
from typing import Any, Dict, TypeVar, overload
from weakref import ReferenceType, finalize, ref
GLOBAL_REFS: Dict[Any, ReferenceType] = {}
T = TypeVar("T")
# NOTE: `ReferenceType` is NOT a generic pre-3.9
if sys.version_info >= (3, 9):
@overload # type: ignore[misc]
def cached_ref(obj: T) -> ReferenceType[T]: ... # noqa: E704
def cached_ref(obj: Any) -> ReferenceType:
"""
Same as `weakref.ref()`, creating a weak reference to a given objet.
But unlike `weakref.ref()`, this function also caches the result,
so it returns the same reference for the same object.
"""
if obj not in GLOBAL_REFS:
GLOBAL_REFS[obj] = ref(obj)
# Remove this entry from GLOBAL_REFS when the object is deleted.
finalize(obj, lambda: GLOBAL_REFS.pop(obj))
return GLOBAL_REFS[obj]