diff --git a/CHANGELOG.md b/CHANGELOG.md index 45e60bdd..e1699eb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Release notes +## v0.131 + +#### Feat + +- `@djc_test` decorator for writing tests that involve Components. + + - The decorator manages global state, ensuring that tests don't leak. + - If using `pytest`, the decorator allows you to parametrize Django or Components settings. + - The decorator also serves as a stand-in for Django's `@override_settings`. + + See the API reference for [`@djc_test`](https://django-components.github.io/django-components/0.131/reference/testing_api/#djc_test) for more details. + +#### Internal + +- Settings are now loaded only once, and thus are considered immutable once loaded. Previously, + django-components would load settings from `settings.COMPONENTS` on each access. The new behavior + aligns with Django's settings. + ## v0.130 #### Feat diff --git a/docs/concepts/advanced/.nav.yml b/docs/concepts/advanced/.nav.yml index 5e72f170..2fb50139 100644 --- a/docs/concepts/advanced/.nav.yml +++ b/docs/concepts/advanced/.nav.yml @@ -8,4 +8,5 @@ nav: - Typing and validation: typing_and_validation.md - Custom template tags: template_tags.md - Tag formatters: tag_formatter.md + - Testing: testing.md - Authoring component libraries: authoring_component_libraries.md diff --git a/docs/concepts/advanced/testing.md b/docs/concepts/advanced/testing.md new file mode 100644 index 00000000..83ee3ee5 --- /dev/null +++ b/docs/concepts/advanced/testing.md @@ -0,0 +1,111 @@ +_New in version 0.131_ + +The [`@djc_test`](../../../reference/testing_api#djc_test) decorator is a powerful tool for testing components created with `django-components`. It ensures that each test is properly isolated, preventing components registered in one test from affecting others. + +## Usage + +The [`@djc_test`](../../../reference/testing_api#djc_test) decorator can be applied to functions, methods, or classes. When applied to a class, it recursively decorates all methods starting with `test_`, including those in nested classes. This allows for comprehensive testing of component behavior. + +### Applying to a Function + +To apply [`djc_test`](../../../reference/testing_api#djc_test) to a function, +simply decorate the function as shown below: + +```python +import django +from django_components.testing import djc_test + +django.setup() + +@djc_test +def test_my_component(): + @register("my_component") + class MyComponent(Component): + template = "..." + ... +``` + +### Applying to a Class + +When applied to a class, `djc_test` decorates each `test_` method individually: + +```python +import django +from django_components.testing import djc_test + +django.setup() + +@djc_test +class TestMyComponent: + def test_something(self): + ... + + class Nested: + def test_something_else(self): + ... +``` + +This is equivalent to applying the decorator to each method individually: + +```python +import django +from django_components.testing import djc_test + +django.setup() + +class TestMyComponent: + @djc_test + def test_something(self): + ... + + class Nested: + @djc_test + def test_something_else(self): + ... +``` + +### Arguments + +See the API reference for [`@djc_test`](../../../reference/testing_api#djc_test) for more details. + +### Setting Up Django + +Before using [`djc_test`](../../../reference/testing_api#djc_test), ensure Django is set up: + +```python +import django +from django_components.testing import djc_test + +django.setup() + +@djc_test +def test_my_component(): + ... +``` + +## Example: Parametrizing Context Behavior + +You can parametrize the [context behavior](../../../reference/settings#django_components.app_settings.ComponentsSettings.context_behavior) using [`djc_test`](../../../reference/testing_api#djc_test): + +```python +from django_components.testing import djc_test + +@djc_test( + # Settings applied to all cases + components_settings={ + "app_dirs": ["custom_dir"], + }, + # Parametrized settings + parametrize=( + ["components_settings"], + [ + [{"context_behavior": "django"}], + [{"context_behavior": "isolated"}], + ], + ["django", "isolated"], + ) +) +def test_context_behavior(components_settings): + rendered = MyComponent().render() + ... +``` diff --git a/docs/reference/.nav.yml b/docs/reference/.nav.yml index 41667025..882f0188 100644 --- a/docs/reference/.nav.yml +++ b/docs/reference/.nav.yml @@ -11,3 +11,4 @@ nav: - Template tags: template_tags.md - Template vars: template_vars.md - URLs: urls.md + - Testing API: testing_api.md diff --git a/docs/reference/testing_api.md b/docs/reference/testing_api.md new file mode 100644 index 00000000..2ab0e1e3 --- /dev/null +++ b/docs/reference/testing_api.md @@ -0,0 +1,9 @@ + + +# Testing API + + +::: django_components.testing.djc_test + options: + show_if_no_docstring: true + diff --git a/docs/scripts/reference.py b/docs/scripts/reference.py index c15de89f..a286da50 100644 --- a/docs/scripts/reference.py +++ b/docs/scripts/reference.py @@ -108,6 +108,40 @@ def gen_reference_api(): f.write("\n") +def gen_reference_testing_api(): + """ + Generate documentation for the Python API of `django_components.testing`. + + This takes all public symbols exported from `django_components.testing`. + """ + module = import_module("django_components.testing") + + preface = "\n\n" + preface += (root / "docs/templates/reference_testing_api.md").read_text() + out_file = root / "docs/reference/testing_api.md" + + out_file.parent.mkdir(parents=True, exist_ok=True) + with out_file.open("w", encoding="utf-8") as f: + f.write(preface + "\n\n") + + for name, obj in inspect.getmembers(module): + if ( + name.startswith("_") + or inspect.ismodule(obj) + ): + continue + + # For each entry, generate a mkdocstrings entry, e.g. + # ``` + # ::: django_components.testing.djc_test + # options: + # show_if_no_docstring: true + # ``` + f.write(f"::: {module.__name__}.{name}\n" f" options:\n" f" show_if_no_docstring: true\n") + + f.write("\n") + + def gen_reference_exceptions(): """ Generate documentation for the Exception classes included in the Python API of `django_components`. @@ -739,6 +773,7 @@ def gen_reference(): gen_reference_templatetags() gen_reference_templatevars() gen_reference_signals() + gen_reference_testing_api() # This is run when `gen-files` plugin is run in mkdocs.yml diff --git a/docs/templates/reference_testing_api.md b/docs/templates/reference_testing_api.md new file mode 100644 index 00000000..4bd65a2c --- /dev/null +++ b/docs/templates/reference_testing_api.md @@ -0,0 +1 @@ +# Testing API diff --git a/pyproject.toml b/pyproject.toml index 363c4ce0..563c9f22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,6 +109,7 @@ disallow_untyped_defs = true testpaths = [ "tests" ] +asyncio_mode = "auto" [tool.hatch.env] requires = [ diff --git a/requirements-ci.in b/requirements-ci.in index 3efa14b8..dad5079e 100644 --- a/requirements-ci.in +++ b/requirements-ci.in @@ -4,4 +4,6 @@ playwright requests types-requests whitenoise -asv \ No newline at end of file +asv +pytest-asyncio +pytest-django \ No newline at end of file diff --git a/requirements-ci.txt b/requirements-ci.txt index 783d3c2a..0d56dde5 100644 --- a/requirements-ci.txt +++ b/requirements-ci.txt @@ -4,17 +4,23 @@ # # pip-compile requirements-ci.in # -cachetools==5.5.0 +asv==0.6.4 + # via -r requirements-ci.in +asv-runner==0.2.1 + # via asv +build==1.2.2.post1 + # via asv +cachetools==5.5.2 # via tox -certifi==2024.8.30 +certifi==2025.1.31 # via requests chardet==5.2.0 # via tox -charset-normalizer==3.3.2 +charset-normalizer==3.4.1 # via requests colorama==0.4.6 # via tox -distlib==0.3.8 +distlib==0.3.9 # via virtualenv filelock==3.16.1 # via @@ -24,9 +30,17 @@ greenlet==3.1.1 # via playwright idna==3.10 # via requests +importlib-metadata==8.5.0 + # via asv-runner +iniconfig==2.0.0 + # via pytest +json5==0.10.0 + # via asv packaging==24.2 # via + # build # pyproject-api + # pytest # tox platformdirs==4.3.6 # via @@ -35,18 +49,36 @@ platformdirs==4.3.6 playwright==1.48.0 # via -r requirements-ci.in pluggy==1.5.0 - # via tox + # via + # pytest + # tox pyee==12.0.0 # via playwright +pympler==1.1 + # via asv pyproject-api==1.8.0 # via tox +pyproject-hooks==1.2.0 + # via build +pytest==8.3.4 + # via + # pytest-asyncio + # pytest-django +pytest-asyncio==0.24.0 + # via -r requirements-ci.in +pytest-django==4.10.0 + # via -r requirements-ci.in +pyyaml==6.0.2 + # via asv requests==2.32.3 # via -r requirements-ci.in +tabulate==0.9.0 + # via asv tox==4.24.1 # via # -r requirements-ci.in # tox-gh-actions -tox-gh-actions==3.2.0 +tox-gh-actions==3.3.0 # via -r requirements-ci.in types-requests==2.32.0.20241016 # via -r requirements-ci.in @@ -56,7 +88,11 @@ urllib3==2.2.3 # via # requests # types-requests -virtualenv==20.29.1 - # via tox +virtualenv==20.29.2 + # via + # asv + # tox whitenoise==6.7.0 # via -r requirements-ci.in +zipp==3.20.2 + # via importlib-metadata diff --git a/requirements-dev.in b/requirements-dev.in index 0a7ce5d9..83cda366 100644 --- a/requirements-dev.in +++ b/requirements-dev.in @@ -2,6 +2,8 @@ django djc-core-html-parser tox pytest +pytest-asyncio +pytest-django syrupy flake8 flake8-pyproject diff --git a/requirements-dev.txt b/requirements-dev.txt index f06decbd..2d6bd1b6 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,11 +6,17 @@ # asgiref==3.8.1 # via django -black==24.10.0 +asv==0.6.4 # via -r requirements-dev.in -cachetools==5.5.1 +asv-runner==0.2.1 + # via asv +black==25.1.0 + # via -r requirements-dev.in +build==1.2.2.post1 + # via asv +cachetools==5.5.2 # via tox -certifi==2024.8.30 +certifi==2025.1.31 # via requests cfgv==3.4.0 # via pre-commit @@ -40,14 +46,18 @@ flake8-pyproject==1.2.3 # via -r requirements-dev.in greenlet==3.1.1 # via playwright -identify==2.6.7 +identify==2.6.8 # via pre-commit idna==3.10 # via requests +importlib-metadata==8.5.0 + # via asv-runner iniconfig==2.0.0 # via pytest -isort==6.0.0 +isort==6.0.1 # via -r requirements-dev.in +json5==0.10.0 + # via asv mccabe==0.7.0 # via flake8 mypy==1.15.0 @@ -61,6 +71,7 @@ nodeenv==1.9.1 packaging==24.2 # via # black + # build # pyproject-api # pytest # tox @@ -71,7 +82,7 @@ platformdirs==4.3.6 # black # tox # virtualenv -playwright==1.49.0 +playwright==1.48.0 # via -r requirements-dev.in pluggy==1.5.0 # via @@ -86,19 +97,39 @@ pyee==12.0.0 pyflakes==3.2.0 # via flake8 pygments==2.19.1 - # via pygments-djc + # via + # -r requirements-dev.in + # pygments-djc pygments-djc==1.0.1 # via -r requirements-dev.in +pympler==1.1 + # via asv pyproject-api==1.8.0 # via tox +pyproject-hooks==1.2.0 + # via build pytest==8.3.4 + # via + # -r requirements-dev.in + # pytest-asyncio + # pytest-django + # syrupy +pytest-asyncio==0.24.0 + # via -r requirements-dev.in +pytest-django==4.10.0 # via -r requirements-dev.in pyyaml==6.0.2 - # via pre-commit + # via + # asv + # pre-commit requests==2.32.3 # via -r requirements-dev.in sqlparse==0.5.3 # via django +syrupy==4.8.2 + # via -r requirements-dev.in +tabulate==0.9.0 + # via asv tox==4.24.1 # via -r requirements-dev.in types-requests==2.32.0.20241016 @@ -111,9 +142,12 @@ urllib3==2.2.3 # via # requests # types-requests -virtualenv==20.28.0 +virtualenv==20.29.2 # via + # asv # pre-commit # tox -whitenoise==6.8.2 +whitenoise==6.7.0 # via -r requirements-dev.in +zipp==3.20.2 + # via importlib-metadata diff --git a/src/django_components/app_settings.py b/src/django_components/app_settings.py index 8546109b..1e1520b1 100644 --- a/src/django_components/app_settings.py +++ b/src/django_components/app_settings.py @@ -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() diff --git a/src/django_components/apps.py b/src/django_components/apps.py index 171e7c19..387a9689 100644 --- a/src/django_components/apps.py +++ b/src/django_components/apps.py @@ -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 diff --git a/src/django_components/autodiscovery.py b/src/django_components/autodiscovery.py index 433a0fe5..c4c52130 100644 --- a/src/django_components/autodiscovery.py +++ b/src/django_components/autodiscovery.py @@ -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 diff --git a/src/django_components/component.py b/src/django_components/component.py index 4ad219b3..23a27f89 100644 --- a/src/django_components/component.py +++ b/src/django_components/component.py @@ -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) diff --git a/src/django_components/component_media.py b/src/django_components/component_media.py index c0b21899..8ae6e771 100644 --- a/src/django_components/component_media.py +++ b/src/django_components/component_media.py @@ -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. diff --git a/src/django_components/component_registry.py b/src/django_components/component_registry.py index bfe469ea..6a2c4bd4 100644 --- a/src/django_components/component_registry.py +++ b/src/django_components/component_registry.py @@ -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"]: diff --git a/src/django_components/components/dynamic.py b/src/django_components/components/dynamic.py index f12a7bf5..2ade057a 100644 --- a/src/django_components/components/dynamic.py +++ b/src/django_components/components/dynamic.py @@ -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 diff --git a/src/django_components/expression.py b/src/django_components/expression.py index f2e28606..9afcbc80 100644 --- a/src/django_components/expression.py +++ b/src/django_components/expression.py @@ -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 diff --git a/src/django_components/testing.py b/src/django_components/testing.py new file mode 100644 index 00000000..262daa20 --- /dev/null +++ b/src/django_components/testing.py @@ -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", +] diff --git a/src/django_components/util/testing.py b/src/django_components/util/testing.py new file mode 100644 index 00000000..95389731 --- /dev/null +++ b/src/django_components/util/testing.py @@ -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, "
1
"], [2, "
2
"]], + 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 diff --git a/src/django_components/util/weakref.py b/src/django_components/util/weakref.py new file mode 100644 index 00000000..55a56396 --- /dev/null +++ b/src/django_components/util/weakref.py @@ -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] diff --git a/tests/django_test_setup.py b/tests/django_test_setup.py deleted file mode 100644 index 63807704..00000000 --- a/tests/django_test_setup.py +++ /dev/null @@ -1,54 +0,0 @@ -from pathlib import Path -from typing import Dict, Optional - -import django -from django.conf import settings - - -def setup_test_config( - components: Optional[Dict] = None, - extra_settings: Optional[Dict] = None, -): - if settings.configured: - return - - default_settings = { - "BASE_DIR": Path(__file__).resolve().parent, - "INSTALLED_APPS": ("django_components", "tests.test_app"), - "TEMPLATES": [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [ - "tests/templates/", - "tests/components/", # Required for template relative imports in tests - ], - "OPTIONS": { - "builtins": [ - "django_components.templatetags.component_tags", - ] - }, - } - ], - "COMPONENTS": { - "template_cache_size": 128, - **(components or {}), - }, - "MIDDLEWARE": ["django_components.middleware.ComponentDependencyMiddleware"], - "DATABASES": { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": ":memory:", - } - }, - "SECRET_KEY": "secret", - "ROOT_URLCONF": "django_components.urls", - } - - settings.configure( - **{ - **default_settings, - **(extra_settings or {}), - } - ) - - django.setup() diff --git a/tests/test_attributes.py b/tests/test_attributes.py index cf106df8..054c7176 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -1,86 +1,66 @@ +import re + +import pytest from django.template import Context, Template, TemplateSyntaxError from django.utils.safestring import SafeString, mark_safe +from pytest_django.asserts import assertHTMLEqual from django_components import Component, register, types from django_components.attributes import append_attributes, attributes_to_string +from django_components.testing import djc_test -from .django_test_setup import setup_test_config -from .testutils import BaseTestCase, parametrize_context_behavior +from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config setup_test_config({"autodiscover": False}) -class AttributesToStringTest(BaseTestCase): +@djc_test +class TestAttributesToString: def test_simple_attribute(self): - self.assertEqual( - attributes_to_string({"foo": "bar"}), - 'foo="bar"', - ) + assert attributes_to_string({"foo": "bar"}) == 'foo="bar"' def test_multiple_attributes(self): - self.assertEqual( - attributes_to_string({"class": "foo", "style": "color: red;"}), - 'class="foo" style="color: red;"', - ) + assert attributes_to_string({"class": "foo", "style": "color: red;"}) == 'class="foo" style="color: red;"' def test_escapes_special_characters(self): - self.assertEqual( - attributes_to_string({"x-on:click": "bar", "@click": "'baz'"}), - 'x-on:click="bar" @click="'baz'"', - ) + assert attributes_to_string({"x-on:click": "bar", "@click": "'baz'"}) == 'x-on:click="bar" @click="'baz'"' # noqa: E501 def test_does_not_escape_special_characters_if_safe_string(self): - self.assertEqual( - attributes_to_string({"foo": mark_safe("'bar'")}), - "foo=\"'bar'\"", - ) + assert attributes_to_string({"foo": mark_safe("'bar'")}) == "foo=\"'bar'\"" def test_result_is_safe_string(self): result = attributes_to_string({"foo": mark_safe("'bar'")}) - self.assertTrue(isinstance(result, SafeString)) + assert isinstance(result, SafeString) def test_attribute_with_no_value(self): - self.assertEqual( - attributes_to_string({"required": None}), - "", - ) + assert attributes_to_string({"required": None}) == "" def test_attribute_with_false_value(self): - self.assertEqual( - attributes_to_string({"required": False}), - "", - ) + assert attributes_to_string({"required": False}) == "" def test_attribute_with_true_value(self): - self.assertEqual( - attributes_to_string({"required": True}), - "required", - ) + assert attributes_to_string({"required": True}) == "required" -class AppendAttributesTest(BaseTestCase): +@djc_test +class TestAppendAttributes: def test_single_dict(self): - self.assertEqual( - append_attributes(("foo", "bar")), - {"foo": "bar"}, - ) + assert append_attributes(("foo", "bar")) == {"foo": "bar"} def test_appends_dicts(self): - self.assertEqual( - append_attributes(("class", "foo"), ("id", "bar"), ("class", "baz")), - {"class": "foo baz", "id": "bar"}, - ) + assert append_attributes(("class", "foo"), ("id", "bar"), ("class", "baz")) == {"class": "foo baz", "id": "bar"} # noqa: E501 -class HtmlAttrsTests(BaseTestCase): +@djc_test +class TestHtmlAttrs: template_str: types.django_html = """ {% load component_tags %} {% component "test" attrs:@click.stop="dispatch('click_event')" attrs:x-data="{hello: 'world'}" attrs:class=class_var %} {% endcomponent %} """ # noqa: E501 - @parametrize_context_behavior(["django", "isolated"]) - def test_tag_positional_args(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_tag_positional_args(self, components_settings): @register("test") class AttrsComponent(Component): template: types.django_html = """ @@ -98,7 +78,7 @@ class HtmlAttrsTests(BaseTestCase): template = Template(self.template_str) rendered = template.render(Context({"class_var": "padding-top-8"})) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """
@@ -106,7 +86,7 @@ class HtmlAttrsTests(BaseTestCase):
""", # noqa: E501 ) - self.assertNotIn("override-me", rendered) + assert "override-me" not in rendered def test_tag_raises_on_extra_positional_args(self): @register("test") @@ -127,8 +107,9 @@ class HtmlAttrsTests(BaseTestCase): template = Template(self.template_str) - with self.assertRaisesMessage( - TypeError, "Invalid parameters for tag 'html_attrs': takes 2 positional argument(s) but more were given" + with pytest.raises( + TypeError, + match=re.escape("Invalid parameters for tag 'html_attrs': takes 2 positional argument(s) but more were given"), # noqa: E501 ): template.render(Context({"class_var": "padding-top-8"})) @@ -150,7 +131,7 @@ class HtmlAttrsTests(BaseTestCase): template = Template(self.template_str) rendered = template.render(Context({"class_var": "padding-top-8"})) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """
@@ -158,7 +139,7 @@ class HtmlAttrsTests(BaseTestCase):
""", # noqa: E501 ) - self.assertNotIn("override-me", rendered) + assert "override-me" not in rendered def test_tag_kwargs_2(self): @register("test") @@ -178,7 +159,7 @@ class HtmlAttrsTests(BaseTestCase): template = Template(self.template_str) rendered = template.render(Context({"class_var": "padding-top-8"})) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """
@@ -186,7 +167,7 @@ class HtmlAttrsTests(BaseTestCase):
""", # noqa: E501 ) - self.assertNotIn("override-me", rendered) + assert "override-me" not in rendered def test_tag_spread(self): @register("test") @@ -210,7 +191,7 @@ class HtmlAttrsTests(BaseTestCase): template = Template(self.template_str) rendered = template.render(Context({"class_var": "padding-top-8"})) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """
@@ -218,7 +199,7 @@ class HtmlAttrsTests(BaseTestCase):
""", # noqa: E501 ) - self.assertNotIn("override-me", rendered) + assert "override-me" not in rendered def test_tag_aggregate_args(self): @register("test") @@ -237,7 +218,7 @@ class HtmlAttrsTests(BaseTestCase): rendered = template.render(Context({"class_var": "padding-top-8"})) # NOTE: The attrs from self.template_str should be ignored because they are not used. - self.assertHTMLEqual( + assertHTMLEqual( rendered, """
@@ -245,7 +226,7 @@ class HtmlAttrsTests(BaseTestCase):
""", # noqa: E501 ) - self.assertNotIn("override-me", rendered) + assert "override-me" not in rendered # Note: Because there's both `attrs:class` and `defaults:class`, the `attrs`, # it's as if the template tag call was (ignoring the `class` and `data-id` attrs): @@ -268,8 +249,9 @@ class HtmlAttrsTests(BaseTestCase): template = Template(self.template_str) - with self.assertRaisesMessage( - TypeError, "Invalid parameters for tag 'html_attrs': got multiple values for argument 'attrs'" + with pytest.raises( + TypeError, + match=re.escape("Invalid parameters for tag 'html_attrs': got multiple values for argument 'attrs'"), ): template.render(Context({"class_var": "padding-top-8"})) @@ -295,9 +277,9 @@ class HtmlAttrsTests(BaseTestCase): template = Template(self.template_str) - with self.assertRaisesMessage( + with pytest.raises( TemplateSyntaxError, - "Received argument 'defaults' both as a regular input", + match=re.escape("Received argument 'defaults' both as a regular input"), ): template.render(Context({"class_var": "padding-top-8"})) @@ -316,7 +298,7 @@ class HtmlAttrsTests(BaseTestCase): template = Template(self.template_str) rendered = template.render(Context({"class_var": "padding-top-8"})) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """
@@ -345,7 +327,7 @@ class HtmlAttrsTests(BaseTestCase): """ # noqa: E501 template = Template(template_str) rendered = template.render(Context({"class_var": "padding-top-8"})) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """
@@ -353,7 +335,7 @@ class HtmlAttrsTests(BaseTestCase):
""", # noqa: E501 ) - self.assertNotIn("override-me", rendered) + assert "override-me" not in rendered def test_tag_no_attrs_no_defaults(self): @register("test") @@ -370,7 +352,7 @@ class HtmlAttrsTests(BaseTestCase): template = Template(self.template_str) rendered = template.render(Context({"class_var": "padding-top-8"})) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """
@@ -378,7 +360,7 @@ class HtmlAttrsTests(BaseTestCase):
""", ) - self.assertNotIn("override-me", rendered) + assert "override-me" not in rendered def test_tag_empty(self): @register("test") @@ -398,7 +380,7 @@ class HtmlAttrsTests(BaseTestCase): template = Template(self.template_str) rendered = template.render(Context({"class_var": "padding-top-8"})) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """
@@ -406,7 +388,7 @@ class HtmlAttrsTests(BaseTestCase):
""", ) - self.assertNotIn("override-me", rendered) + assert "override-me" not in rendered def test_tag_null_attrs_and_defaults(self): @register("test") @@ -426,7 +408,7 @@ class HtmlAttrsTests(BaseTestCase): template = Template(self.template_str) rendered = template.render(Context({"class_var": "padding-top-8"})) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """
@@ -434,4 +416,4 @@ class HtmlAttrsTests(BaseTestCase):
""", ) - self.assertNotIn("override-me", rendered) + assert "override-me" not in rendered diff --git a/tests/test_autodiscover.py b/tests/test_autodiscover.py index edfc2a16..142a0552 100644 --- a/tests/test_autodiscover.py +++ b/tests/test_autodiscover.py @@ -1,65 +1,60 @@ import sys -from unittest import TestCase -from django.conf import settings +import pytest from django_components import AlreadyRegistered, registry from django_components.autodiscovery import autodiscover, import_libraries +from django_components.testing import djc_test -from .django_test_setup import setup_test_config +from .testutils import setup_test_config + +setup_test_config({"autodiscover": False}) -# NOTE: This is different from BaseTestCase in testutils.py, because here we need -# TestCase instead of SimpleTestCase. -class _TestCase(TestCase): - def tearDown(self) -> None: - super().tearDown() - registry.clear() - - -class TestAutodiscover(_TestCase): +@djc_test +class TestAutodiscover: def test_autodiscover(self): - setup_test_config({"autodiscover": False}) - all_components = registry.all().copy() - self.assertNotIn("single_file_component", all_components) - self.assertNotIn("multi_file_component", all_components) - self.assertNotIn("relative_file_component", all_components) - self.assertNotIn("relative_file_pathobj_component", all_components) + assert "single_file_component" not in all_components + assert "multi_file_component" not in all_components + assert "relative_file_component" not in all_components + assert "relative_file_pathobj_component" not in all_components try: modules = autodiscover(map_module=lambda p: "tests." + p if p.startswith("components") else p) except AlreadyRegistered: - self.fail("Autodiscover should not raise AlreadyRegistered exception") + pytest.fail("Autodiscover should not raise AlreadyRegistered exception") - self.assertIn("tests.components", modules) - self.assertIn("tests.components.single_file", modules) - self.assertIn("tests.components.staticfiles.staticfiles", modules) - self.assertIn("tests.components.multi_file.multi_file", modules) - self.assertIn("tests.components.relative_file_pathobj.relative_file_pathobj", modules) - self.assertIn("tests.components.relative_file.relative_file", modules) - self.assertIn("tests.test_app.components.app_lvl_comp.app_lvl_comp", modules) - self.assertIn("django_components.components", modules) - self.assertIn("django_components.components.dynamic", modules) + assert "tests.components" in modules + assert "tests.components.single_file" in modules + assert "tests.components.staticfiles.staticfiles" in modules + assert "tests.components.multi_file.multi_file" in modules + assert "tests.components.relative_file_pathobj.relative_file_pathobj" in modules + assert "tests.components.relative_file.relative_file" in modules + assert "tests.test_app.components.app_lvl_comp.app_lvl_comp" in modules + assert "django_components.components" in modules + assert "django_components.components.dynamic" in modules all_components = registry.all().copy() - self.assertIn("single_file_component", all_components) - self.assertIn("multi_file_component", all_components) - self.assertIn("relative_file_component", all_components) - self.assertIn("relative_file_pathobj_component", all_components) + assert "single_file_component" in all_components + assert "multi_file_component" in all_components + assert "relative_file_component" in all_components + assert "relative_file_pathobj_component" in all_components -class TestImportLibraries(_TestCase): +@djc_test +class TestImportLibraries: + @djc_test( + components_settings={ + "libraries": ["tests.components.single_file", "tests.components.multi_file.multi_file"] + } + ) def test_import_libraries(self): - # Prepare settings - setup_test_config({"autodiscover": False}) - settings.COMPONENTS["libraries"] = ["tests.components.single_file", "tests.components.multi_file.multi_file"] - # Ensure we start with a clean state registry.clear() all_components = registry.all().copy() - self.assertNotIn("single_file_component", all_components) - self.assertNotIn("multi_file_component", all_components) + assert "single_file_component" not in all_components + assert "multi_file_component" not in all_components # Ensure that the modules are executed again after import if "tests.components.single_file" in sys.modules: @@ -70,31 +65,26 @@ class TestImportLibraries(_TestCase): try: modules = import_libraries() except AlreadyRegistered: - self.fail("Autodiscover should not raise AlreadyRegistered exception") + pytest.fail("Autodiscover should not raise AlreadyRegistered exception") - self.assertIn("tests.components.single_file", modules) - self.assertIn("tests.components.multi_file.multi_file", modules) + assert "tests.components.single_file" in modules + assert "tests.components.multi_file.multi_file" in modules all_components = registry.all().copy() - self.assertIn("single_file_component", all_components) - self.assertIn("multi_file_component", all_components) - - settings.COMPONENTS["libraries"] = [] + assert "single_file_component" in all_components + assert "multi_file_component" in all_components + @djc_test( + components_settings={ + "libraries": ["components.single_file", "components.multi_file.multi_file"] + } + ) def test_import_libraries_map_modules(self): - # Prepare settings - setup_test_config( - { - "autodiscover": False, - } - ) - settings.COMPONENTS["libraries"] = ["components.single_file", "components.multi_file.multi_file"] - # Ensure we start with a clean state registry.clear() all_components = registry.all().copy() - self.assertNotIn("single_file_component", all_components) - self.assertNotIn("multi_file_component", all_components) + assert "single_file_component" not in all_components + assert "multi_file_component" not in all_components # Ensure that the modules are executed again after import if "tests.components.single_file" in sys.modules: @@ -105,13 +95,11 @@ class TestImportLibraries(_TestCase): try: modules = import_libraries(map_module=lambda p: "tests." + p if p.startswith("components") else p) except AlreadyRegistered: - self.fail("Autodiscover should not raise AlreadyRegistered exception") + pytest.fail("Autodiscover should not raise AlreadyRegistered exception") - self.assertIn("tests.components.single_file", modules) - self.assertIn("tests.components.multi_file.multi_file", modules) + assert "tests.components.single_file" in modules + assert "tests.components.multi_file.multi_file" in modules all_components = registry.all().copy() - self.assertIn("single_file_component", all_components) - self.assertIn("multi_file_component", all_components) - - settings.COMPONENTS["libraries"] = [] + assert "single_file_component" in all_components + assert "multi_file_component" in all_components diff --git a/tests/test_benchmark_django.py b/tests/test_benchmark_django.py index 6f6df236..ee947435 100644 --- a/tests/test_benchmark_django.py +++ b/tests/test_benchmark_django.py @@ -6431,17 +6431,10 @@ def project_output_form(context: Context, data: ProjectOutputFormData): # The code above is used also used when benchmarking. # The section below is NOT included. -from .testutils import CsrfTokenPatcher, GenIdPatcher # noqa: E402 +from django_components.testing import djc_test # noqa: E402 +@djc_test def test_render(snapshot): - id_patcher = GenIdPatcher() - id_patcher.start() - csrf_token_patcher = CsrfTokenPatcher() - csrf_token_patcher.start() - data = gen_render_data() rendered = render(data) assert rendered == snapshot - - id_patcher.stop() - csrf_token_patcher.stop() diff --git a/tests/test_benchmark_django_small.py b/tests/test_benchmark_django_small.py index 08dd50e0..a4965d34 100644 --- a/tests/test_benchmark_django_small.py +++ b/tests/test_benchmark_django_small.py @@ -332,17 +332,10 @@ def button(context: Context, data: ButtonData): # The code above is used also used when benchmarking. # The section below is NOT included. -from .testutils import CsrfTokenPatcher, GenIdPatcher # noqa: E402 +from django_components.testing import djc_test # noqa: E402 +@djc_test def test_render(snapshot): - id_patcher = GenIdPatcher() - id_patcher.start() - csrf_token_patcher = CsrfTokenPatcher() - csrf_token_patcher.start() - data = gen_render_data() rendered = render(data) assert rendered == snapshot - - id_patcher.stop() - csrf_token_patcher.stop() diff --git a/tests/test_benchmark_djc.py b/tests/test_benchmark_djc.py index ef1837c7..8bf9fac1 100644 --- a/tests/test_benchmark_djc.py +++ b/tests/test_benchmark_djc.py @@ -6007,14 +6007,10 @@ class ProjectOutputForm(Component): # The code above is used also used when benchmarking. # The section below is NOT included. -from .testutils import CsrfTokenPatcher, GenIdPatcher # noqa: E402 +from django_components.testing import djc_test # noqa: E402 +@djc_test def test_render(snapshot): - id_patcher = GenIdPatcher() - id_patcher.start() - csrf_token_patcher = CsrfTokenPatcher() - csrf_token_patcher.start() - registry.register("Button", Button) registry.register("Menu", Menu) registry.register("MenuList", MenuList) @@ -6055,6 +6051,3 @@ def test_render(snapshot): data = gen_render_data() rendered = render(data) assert rendered == snapshot - - id_patcher.stop() - csrf_token_patcher.stop() diff --git a/tests/test_benchmark_djc_small.py b/tests/test_benchmark_djc_small.py index c2f5820f..6a82949e 100644 --- a/tests/test_benchmark_djc_small.py +++ b/tests/test_benchmark_djc_small.py @@ -335,17 +335,10 @@ class Button(Component): # The code above is used also used when benchmarking. # The section below is NOT included. -from .testutils import CsrfTokenPatcher, GenIdPatcher # noqa: E402 +from django_components.testing import djc_test # noqa: E402 +@djc_test def test_render(snapshot): - id_patcher = GenIdPatcher() - id_patcher.start() - csrf_token_patcher = CsrfTokenPatcher() - csrf_token_patcher.start() - data = gen_render_data() rendered = render(data) assert rendered == snapshot - - id_patcher.stop() - csrf_token_patcher.stop() diff --git a/tests/test_cache.py b/tests/test_cache.py index d1c32af8..31825112 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1,15 +1,16 @@ -from django.test import TestCase, override_settings from django.core.cache.backends.locmem import LocMemCache -from django_components.util.cache import LRUCache from django_components import Component, register +from django_components.testing import djc_test +from django_components.util.cache import LRUCache -from .django_test_setup import setup_test_config +from .testutils import setup_test_config setup_test_config({"autodiscover": False}) -class CacheTests(TestCase): +@djc_test +class TestCache: def test_cache(self): cache = LRUCache[int](maxsize=3) @@ -17,58 +18,59 @@ class CacheTests(TestCase): cache.set("b", 2) cache.set("c", 3) - self.assertEqual(cache.get("a"), 1) - self.assertEqual(cache.get("b"), 2) - self.assertEqual(cache.get("c"), 3) + assert cache.get("a") == 1 + assert cache.get("b") == 2 + assert cache.get("c") == 3 cache.set("d", 4) - self.assertEqual(cache.get("a"), None) - self.assertEqual(cache.get("b"), 2) - self.assertEqual(cache.get("c"), 3) - self.assertEqual(cache.get("d"), 4) + assert cache.get("a") is None + assert cache.get("b") == 2 + assert cache.get("c") == 3 + assert cache.get("d") == 4 cache.set("e", 5) cache.set("f", 6) - self.assertEqual(cache.get("b"), None) - self.assertEqual(cache.get("c"), None) - self.assertEqual(cache.get("d"), 4) - self.assertEqual(cache.get("e"), 5) - self.assertEqual(cache.get("f"), 6) + assert cache.get("b") is None + assert cache.get("c") is None + assert cache.get("d") == 4 + assert cache.get("e") == 5 + assert cache.get("f") == 6 cache.clear() - self.assertEqual(cache.get("d"), None) - self.assertEqual(cache.get("e"), None) - self.assertEqual(cache.get("f"), None) + assert cache.get("d") is None + assert cache.get("e") is None + assert cache.get("f") is None def test_cache_maxsize_zero(self): cache = LRUCache[int](maxsize=0) cache.set("a", 1) - self.assertEqual(cache.get("a"), None) + assert cache.get("a") is None cache.set("b", 2) cache.set("c", 3) - self.assertEqual(cache.get("b"), None) - self.assertEqual(cache.get("c"), None) + assert cache.get("b") is None + assert cache.get("c") is None # Same with negative numbers cache = LRUCache[int](maxsize=-1) cache.set("a", 1) - self.assertEqual(cache.get("a"), None) + assert cache.get("a") is None cache.set("b", 2) cache.set("c", 3) - self.assertEqual(cache.get("b"), None) - self.assertEqual(cache.get("c"), None) + assert cache.get("b") is None + assert cache.get("c") is None -class ComponentMediaCacheTests(TestCase): - def setUp(self): - # Create a custom locmem cache for testing - self.test_cache = LocMemCache( +@djc_test +class TestComponentMediaCache: + @djc_test(components_settings={"cache": "test-cache"}) + def test_component_media_caching(self): + test_cache = LocMemCache( "test-cache", { "TIMEOUT": None, # No timeout @@ -77,8 +79,6 @@ class ComponentMediaCacheTests(TestCase): }, ) - @override_settings(COMPONENTS={"cache": "test-cache"}) - def test_component_media_caching(self): @register("test_simple") class TestSimpleComponent(Component): template = """ @@ -123,28 +123,22 @@ class ComponentMediaCacheTests(TestCase): # Register our test cache from django.core.cache import caches - caches["test-cache"] = self.test_cache + caches["test-cache"] = test_cache # Render the components to trigger caching TestMediaAndVarsComponent.render() # Check that JS/CSS is cached for components that have them - self.assertTrue(self.test_cache.has_key(f"__components:{TestMediaAndVarsComponent._class_hash}:js")) - self.assertTrue(self.test_cache.has_key(f"__components:{TestMediaAndVarsComponent._class_hash}:css")) - self.assertTrue(self.test_cache.has_key(f"__components:{TestMediaNoVarsComponent._class_hash}:js")) - self.assertTrue(self.test_cache.has_key(f"__components:{TestMediaNoVarsComponent._class_hash}:css")) - self.assertFalse(self.test_cache.has_key(f"__components:{TestSimpleComponent._class_hash}:js")) - self.assertFalse(self.test_cache.has_key(f"__components:{TestSimpleComponent._class_hash}:css")) + assert test_cache.has_key(f"__components:{TestMediaAndVarsComponent._class_hash}:js") + assert test_cache.has_key(f"__components:{TestMediaAndVarsComponent._class_hash}:css") + assert test_cache.has_key(f"__components:{TestMediaNoVarsComponent._class_hash}:js") + assert test_cache.has_key(f"__components:{TestMediaNoVarsComponent._class_hash}:css") + assert not test_cache.has_key(f"__components:{TestSimpleComponent._class_hash}:js") + assert not test_cache.has_key(f"__components:{TestSimpleComponent._class_hash}:css") # Check that we cache `Component.js` / `Component.css` - self.assertEqual( - self.test_cache.get(f"__components:{TestMediaNoVarsComponent._class_hash}:js").strip(), - "console.log('Hello from JS');", - ) - self.assertEqual( - self.test_cache.get(f"__components:{TestMediaNoVarsComponent._class_hash}:css").strip(), - ".novars-component { color: blue; }", - ) + assert test_cache.get(f"__components:{TestMediaNoVarsComponent._class_hash}:js").strip() == "console.log('Hello from JS');" # noqa: E501 + assert test_cache.get(f"__components:{TestMediaNoVarsComponent._class_hash}:css").strip() == ".novars-component { color: blue; }" # noqa: E501 # Check that we cache JS / CSS scripts generated from `get_js_data` / `get_css_data` # NOTE: The hashes is generated from the data. @@ -152,11 +146,5 @@ class ComponentMediaCacheTests(TestCase): css_vars_hash = "d039a3" # TODO - Update once JS and CSS vars are enabled - self.assertEqual( - self.test_cache.get(f"__components:{TestMediaAndVarsComponent._class_hash}:js:{js_vars_hash}").strip(), - "", - ) - self.assertEqual( - self.test_cache.get(f"__components:{TestMediaAndVarsComponent._class_hash}:css:{css_vars_hash}").strip(), - "", - ) + assert test_cache.get(f"__components:{TestMediaAndVarsComponent._class_hash}:js:{js_vars_hash}").strip() == "" + assert test_cache.get(f"__components:{TestMediaAndVarsComponent._class_hash}:css:{css_vars_hash}").strip() == "" # noqa: E501 diff --git a/tests/test_command_startcomponent.py b/tests/test_command_startcomponent.py index 5e438c4f..411c69d7 100644 --- a/tests/test_command_startcomponent.py +++ b/tests/test_command_startcomponent.py @@ -3,43 +3,43 @@ import tempfile from io import StringIO from shutil import rmtree +import pytest from django.core.management import call_command from django.core.management.base import CommandError -from django.test import TestCase -from .django_test_setup import setup_test_config +from django_components.testing import djc_test +from .testutils import setup_test_config setup_test_config() -class CreateComponentCommandTest(TestCase): - def setUp(self): - super().setUp() - self.temp_dir = tempfile.mkdtemp() - - def tearDown(self): - super().tearDown() - rmtree(self.temp_dir) - +@djc_test +class TestCreateComponentCommand: def test_default_file_names(self): + temp_dir = tempfile.mkdtemp() + component_name = "defaultcomponent" - call_command("startcomponent", component_name, "--path", self.temp_dir) + call_command("startcomponent", component_name, "--path", temp_dir) expected_files = [ - os.path.join(self.temp_dir, component_name, "script.js"), - os.path.join(self.temp_dir, component_name, "style.css"), - os.path.join(self.temp_dir, component_name, "template.html"), + os.path.join(temp_dir, component_name, "script.js"), + os.path.join(temp_dir, component_name, "style.css"), + os.path.join(temp_dir, component_name, "template.html"), ] for file_path in expected_files: - self.assertTrue(os.path.exists(file_path)) + assert os.path.exists(file_path) + + rmtree(temp_dir) def test_nondefault_creation(self): + temp_dir = tempfile.mkdtemp() + component_name = "testcomponent" call_command( "startcomponent", component_name, "--path", - self.temp_dir, + temp_dir, "--js", "test.js", "--css", @@ -49,31 +49,39 @@ class CreateComponentCommandTest(TestCase): ) expected_files = [ - os.path.join(self.temp_dir, component_name, "test.js"), - os.path.join(self.temp_dir, component_name, "test.css"), - os.path.join(self.temp_dir, component_name, "test.html"), - os.path.join(self.temp_dir, component_name, f"{component_name}.py"), + os.path.join(temp_dir, component_name, "test.js"), + os.path.join(temp_dir, component_name, "test.css"), + os.path.join(temp_dir, component_name, "test.html"), + os.path.join(temp_dir, component_name, f"{component_name}.py"), ] for file_path in expected_files: - self.assertTrue(os.path.exists(file_path), f"File {file_path} was not created") + assert os.path.exists(file_path), f"File {file_path} was not created" + + rmtree(temp_dir) def test_dry_run(self): + temp_dir = tempfile.mkdtemp() + component_name = "dryruncomponent" call_command( "startcomponent", component_name, "--path", - self.temp_dir, + temp_dir, "--dry-run", ) - component_path = os.path.join(self.temp_dir, component_name) - self.assertFalse(os.path.exists(component_path)) + component_path = os.path.join(temp_dir, component_name) + assert not os.path.exists(component_path) + + rmtree(temp_dir) def test_force_overwrite(self): + temp_dir = tempfile.mkdtemp() + component_name = "existingcomponent" - component_path = os.path.join(self.temp_dir, component_name) + component_path = os.path.join(temp_dir, component_name) os.makedirs(component_path) with open(os.path.join(component_path, f"{component_name}.py"), "w") as f: @@ -83,31 +91,41 @@ class CreateComponentCommandTest(TestCase): "startcomponent", component_name, "--path", - self.temp_dir, + temp_dir, "--force", ) with open(os.path.join(component_path, f"{component_name}.py"), "r") as f: - self.assertNotIn("hello world", f.read()) + assert "hello world" not in f.read() + + rmtree(temp_dir) def test_error_existing_component_no_force(self): + temp_dir = tempfile.mkdtemp() + component_name = "existingcomponent_2" - component_path = os.path.join(self.temp_dir, component_name) + component_path = os.path.join(temp_dir, component_name) os.makedirs(component_path) - with self.assertRaises(CommandError): - call_command("startcomponent", component_name, "--path", self.temp_dir) + with pytest.raises(CommandError): + call_command("startcomponent", component_name, "--path", temp_dir) + + rmtree(temp_dir) def test_verbose_output(self): + temp_dir = tempfile.mkdtemp() + component_name = "verbosecomponent" out = StringIO() call_command( "startcomponent", component_name, "--path", - self.temp_dir, + temp_dir, "--verbose", stdout=out, ) output = out.getvalue() - self.assertIn("component at", output) + assert "component at" in output + + rmtree(temp_dir) diff --git a/tests/test_component.py b/tests/test_component.py index c477292d..3fef56d8 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -15,6 +15,7 @@ else: from unittest import skipIf +import pytest from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.http import HttpRequest, HttpResponse @@ -23,13 +24,14 @@ from django.template.base import TextNode from django.test import Client from django.urls import path from django.utils.safestring import SafeString +from pytest_django.asserts import assertHTMLEqual, assertInHTML -from django_components import Component, ComponentView, Slot, SlotFunc, register, registry, types +from django_components import Component, ComponentView, Slot, SlotFunc, register, types from django_components.slots import SlotRef from django_components.urls import urlpatterns as dc_urlpatterns -from .django_test_setup import setup_test_config -from .testutils import BaseTestCase, parametrize_context_behavior +from django_components.testing import djc_test +from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config setup_test_config({"autodiscover": False}) @@ -78,9 +80,10 @@ else: # TODO_REMOVE_IN_V1 - Superseded by `self.get_template` in v1 -class ComponentOldTemplateApiTest(BaseTestCase): - @parametrize_context_behavior(["django", "isolated"]) - def test_get_template_string(self): +@djc_test +class TestComponentOldTemplateApi: + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_get_template_string(self, components_settings): class SimpleComponent(Component): def get_template_string(self, context): content: types.django_html = """ @@ -98,7 +101,7 @@ class ComponentOldTemplateApiTest(BaseTestCase): js = "script.js" rendered = SimpleComponent.render(kwargs={"variable": "test"}) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ Variable: test @@ -106,57 +109,18 @@ class ComponentOldTemplateApiTest(BaseTestCase): ) -class ComponentTest(BaseTestCase): - class ParentComponent(Component): - template: types.django_html = """ - {% load component_tags %} -
-

Parent content

- {% component name="variable_display" shadowing_variable='override' new_variable='unique_val' %} - {% endcomponent %} -
-
- {% slot 'content' %} -

Slot content

- {% component name="variable_display" shadowing_variable='slot_default_override' new_variable='slot_default_unique' %} - {% endcomponent %} - {% endslot %} -
- """ # noqa - - def get_context_data(self): - return {"shadowing_variable": "NOT SHADOWED"} - - class VariableDisplay(Component): - template: types.django_html = """ - {% load component_tags %} -

Shadowing variable = {{ shadowing_variable }}

-

Uniquely named variable = {{ unique_variable }}

- """ - - def get_context_data(self, shadowing_variable=None, new_variable=None): - context = {} - if shadowing_variable is not None: - context["shadowing_variable"] = shadowing_variable - if new_variable is not None: - context["unique_variable"] = new_variable - return context - - def setUp(self): - super().setUp() - registry.register(name="parent_component", component=self.ParentComponent) - registry.register(name="variable_display", component=self.VariableDisplay) - - @parametrize_context_behavior(["django", "isolated"]) - def test_empty_component(self): +@djc_test +class TestComponent: + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_empty_component(self, components_settings): class EmptyComponent(Component): pass - with self.assertRaises(ImproperlyConfigured): + with pytest.raises(ImproperlyConfigured): EmptyComponent("empty_component")._get_template(Context({}), "123") - @parametrize_context_behavior(["django", "isolated"]) - def test_template_string_static_inlined(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_template_string_static_inlined(self, components_settings): class SimpleComponent(Component): template: types.django_html = """ Variable: {{ variable }} @@ -172,15 +136,15 @@ class ComponentTest(BaseTestCase): js = "script.js" rendered = SimpleComponent.render(kwargs={"variable": "test"}) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ Variable: test """, ) - @parametrize_context_behavior(["django", "isolated"]) - def test_template_string_dynamic(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_template_string_dynamic(self, components_settings): class SimpleComponent(Component): def get_template(self, context): content: types.django_html = """ @@ -198,15 +162,15 @@ class ComponentTest(BaseTestCase): js = "script.js" rendered = SimpleComponent.render(kwargs={"variable": "test"}) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ Variable: test """, ) - @parametrize_context_behavior(["django", "isolated"]) - def test_template_file_static(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_template_file_static(self, components_settings): class SimpleComponent(Component): template_file = "simple_template.html" @@ -220,15 +184,15 @@ class ComponentTest(BaseTestCase): js = "script.js" rendered = SimpleComponent.render(kwargs={"variable": "test"}) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ Variable: test """, ) - @parametrize_context_behavior(["django", "isolated"]) - def test_template_file_static__compat(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_template_file_static__compat(self, components_settings): class SimpleComponent(Component): template_name = "simple_template.html" @@ -241,16 +205,16 @@ class ComponentTest(BaseTestCase): css = "style.css" js = "script.js" - self.assertEqual(SimpleComponent.template_name, "simple_template.html") - self.assertEqual(SimpleComponent.template_file, "simple_template.html") + assert SimpleComponent.template_name == "simple_template.html" + assert SimpleComponent.template_file == "simple_template.html" SimpleComponent.template_name = "other_template.html" - self.assertEqual(SimpleComponent.template_name, "other_template.html") - self.assertEqual(SimpleComponent.template_file, "other_template.html") + assert SimpleComponent.template_name == "other_template.html" + assert SimpleComponent.template_file == "other_template.html" SimpleComponent.template_name = "simple_template.html" rendered = SimpleComponent.render(kwargs={"variable": "test"}) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ Variable: test @@ -258,28 +222,28 @@ class ComponentTest(BaseTestCase): ) comp = SimpleComponent() - self.assertEqual(comp.template_name, "simple_template.html") - self.assertEqual(comp.template_file, "simple_template.html") + assert comp.template_name == "simple_template.html" + assert comp.template_file == "simple_template.html" # NOTE: Setting `template_file` on INSTANCE is not supported, as users should work # with classes and not instances. This is tested for completeness. comp.template_name = "other_template_2.html" - self.assertEqual(comp.template_name, "other_template_2.html") - self.assertEqual(comp.template_file, "other_template_2.html") - self.assertEqual(SimpleComponent.template_name, "other_template_2.html") - self.assertEqual(SimpleComponent.template_file, "other_template_2.html") + assert comp.template_name == "other_template_2.html" + assert comp.template_file == "other_template_2.html" + assert SimpleComponent.template_name == "other_template_2.html" + assert SimpleComponent.template_file == "other_template_2.html" SimpleComponent.template_name = "simple_template.html" rendered = comp.render(kwargs={"variable": "test"}) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ Variable: test """, ) - @parametrize_context_behavior(["django", "isolated"]) - def test_template_file_dynamic(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_template_file_dynamic(self, components_settings): class SvgComponent(Component): def get_context_data(self, name, css_class="", title="", **attrs): return { @@ -292,21 +256,21 @@ class ComponentTest(BaseTestCase): def get_template_name(self, context): return f"dynamic_{context['name']}.svg" - self.assertHTMLEqual( + assertHTMLEqual( SvgComponent.render(kwargs={"name": "svg1"}), """ Dynamic1 """, ) - self.assertHTMLEqual( + assertHTMLEqual( SvgComponent.render(kwargs={"name": "svg2"}), """ Dynamic2 """, ) - @parametrize_context_behavior(["django", "isolated"]) - def test_allows_to_return_template(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_allows_to_return_template(self, components_settings): class TestComponent(Component): def get_context_data(self, variable, **attrs): return { @@ -318,7 +282,7 @@ class ComponentTest(BaseTestCase): return Template(template_str) rendered = TestComponent.render(kwargs={"variable": "test"}) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ Variable: test @@ -326,16 +290,14 @@ class ComponentTest(BaseTestCase): ) def test_input(self): - tester = self - class TestComponent(Component): @no_type_check def get_context_data(self, var1, var2, variable, another, **attrs): - tester.assertEqual(self.input.args, (123, "str")) - tester.assertEqual(self.input.kwargs, {"variable": "test", "another": 1}) - tester.assertIsInstance(self.input.context, Context) - tester.assertEqual(list(self.input.slots.keys()), ["my_slot"]) - tester.assertEqual(self.input.slots["my_slot"](Context(), None, None), "MY_SLOT") + assert self.input.args == (123, "str") + assert self.input.kwargs == {"variable": "test", "another": 1} + assert isinstance(self.input.context, Context) + assert list(self.input.slots.keys()) == ["my_slot"] + assert self.input.slots["my_slot"](Context(), None, None) == "MY_SLOT" return { "variable": variable, @@ -343,11 +305,11 @@ class ComponentTest(BaseTestCase): @no_type_check def get_template(self, context): - tester.assertEqual(self.input.args, (123, "str")) - tester.assertEqual(self.input.kwargs, {"variable": "test", "another": 1}) - tester.assertIsInstance(self.input.context, Context) - tester.assertEqual(list(self.input.slots.keys()), ["my_slot"]) - tester.assertEqual(self.input.slots["my_slot"](Context(), None, None), "MY_SLOT") + assert self.input.args == (123, "str") + assert self.input.kwargs == {"variable": "test", "another": 1} + assert isinstance(self.input.context, Context) + assert list(self.input.slots.keys()) == ["my_slot"] + assert self.input.slots["my_slot"](Context(), None, None) == "MY_SLOT" template_str: types.django_html = """ {% load component_tags %} @@ -362,15 +324,15 @@ class ComponentTest(BaseTestCase): slots={"my_slot": "MY_SLOT"}, ) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ Variable: test MY_SLOT """, ) - @parametrize_context_behavior(["django", "isolated"]) - def test_prepends_exceptions_with_component_path(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_prepends_exceptions_with_component_path(self, components_settings): @register("broken") class Broken(Component): template: types.django_html = """ @@ -423,15 +385,18 @@ class ComponentTest(BaseTestCase): {% endcomponent %} """ - with self.assertRaisesMessage( + with pytest.raises( TypeError, - "An error occured while rendering components Root > parent > provider > provider(slot:content) > broken:\n" - "tuple indices must be integers or slices, not str", + match=re.escape( + "An error occured while rendering components Root > parent > provider > provider(slot:content) > broken:\n" # noqa: E501 + "tuple indices must be integers or slices, not str" + ), ): Root.render() -class ComponentValidationTest(BaseTestCase): +@djc_test +class TestComponentValidation: def test_validate_input_passes(self): class TestComponent(Component[CompArgs, CompKwargs, CompSlots, CompData, Any, Any]): def get_context_data(self, var1, var2, variable, another, **attrs): @@ -455,7 +420,7 @@ class ComponentValidationTest(BaseTestCase): }, ) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ Variable: test @@ -479,7 +444,10 @@ class ComponentValidationTest(BaseTestCase): Slot 2: {% slot "my_slot2" / %} """ - with self.assertRaisesMessage(TypeError, "Component 'TestComponent' expected 2 positional arguments, got 1"): + with pytest.raises( + TypeError, + match=re.escape("Component 'TestComponent' expected 2 positional arguments, got 1"), + ): TestComponent.render( kwargs={"variable": 1, "another": "test"}, # type: ignore args=(123,), # type: ignore @@ -489,7 +457,10 @@ class ComponentValidationTest(BaseTestCase): }, ) - with self.assertRaisesMessage(TypeError, "Component 'TestComponent' expected 2 positional arguments, got 0"): + with pytest.raises( + TypeError, + match=re.escape("Component 'TestComponent' expected 2 positional arguments, got 0"), + ): TestComponent.render( kwargs={"variable": 1, "another": "test"}, # type: ignore slots={ @@ -498,9 +469,11 @@ class ComponentValidationTest(BaseTestCase): }, ) - with self.assertRaisesMessage( + with pytest.raises( TypeError, - "Component 'TestComponent' expected keyword argument 'variable' to be , got 1 of type ", # noqa: E501 + match=re.escape( + "Component 'TestComponent' expected keyword argument 'variable' to be , got 1 of type " # noqa: E501 + ), ): TestComponent.render( kwargs={"variable": 1, "another": "test"}, # type: ignore @@ -511,12 +484,17 @@ class ComponentValidationTest(BaseTestCase): }, ) - with self.assertRaisesMessage(TypeError, "Component 'TestComponent' expected 2 positional arguments, got 0"): + with pytest.raises( + TypeError, + match=re.escape("Component 'TestComponent' expected 2 positional arguments, got 0"), + ): TestComponent.render() - with self.assertRaisesMessage( + with pytest.raises( TypeError, - "Component 'TestComponent' expected keyword argument 'variable' to be , got 1 of type ", # noqa: E501 + match=re.escape( + "Component 'TestComponent' expected keyword argument 'variable' to be , got 1 of type " # noqa: E501 + ), ): TestComponent.render( kwargs={"variable": 1, "another": "test"}, # type: ignore @@ -527,8 +505,9 @@ class ComponentValidationTest(BaseTestCase): }, ) - with self.assertRaisesMessage( - TypeError, "Component 'TestComponent' is missing a required keyword argument 'another'" + with pytest.raises( + TypeError, + match=re.escape("Component 'TestComponent' is missing a required keyword argument 'another'"), ): TestComponent.render( kwargs={"variable": "abc"}, # type: ignore @@ -539,9 +518,11 @@ class ComponentValidationTest(BaseTestCase): }, ) - with self.assertRaisesMessage( + with pytest.raises( TypeError, - "Component 'TestComponent' expected slot 'my_slot' to be typing.Union[str, int, django_components.slots.Slot], got 123.5 of type ", # noqa: E501 + match=re.escape( + "Component 'TestComponent' expected slot 'my_slot' to be typing.Union[str, int, django_components.slots.Slot], got 123.5 of type " # noqa: E501 + ), ): TestComponent.render( kwargs={"variable": "abc", "another": 1}, @@ -552,7 +533,10 @@ class ComponentValidationTest(BaseTestCase): }, ) - with self.assertRaisesMessage(TypeError, "Component 'TestComponent' is missing a required slot 'my_slot2'"): + with pytest.raises( + TypeError, + match=re.escape("Component 'TestComponent' is missing a required slot 'my_slot2'"), + ): TestComponent.render( kwargs={"variable": "abc", "another": 1}, args=(123, "str"), @@ -584,7 +568,7 @@ class ComponentValidationTest(BaseTestCase): }, ) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ Variable: test @@ -616,7 +600,7 @@ class ComponentValidationTest(BaseTestCase): }, ) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ Variable: test @@ -640,7 +624,10 @@ class ComponentValidationTest(BaseTestCase): Slot 2: {% slot "my_slot2" / %} """ - with self.assertRaisesMessage(TypeError, "Component 'TestComponent' got unexpected data keys 'invalid_key'"): + with pytest.raises( + TypeError, + match=re.escape("Component 'TestComponent' got unexpected data keys 'invalid_key'"), + ): TestComponent.render( kwargs={"variable": "test", "another": 1}, args=(123, "str"), @@ -703,7 +690,7 @@ class ComponentValidationTest(BaseTestCase): rendered = TestComponent.render(args=(Inner(),), kwargs={"inner": Inner()}) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ Name: TestComponent @@ -756,9 +743,10 @@ class ComponentValidationTest(BaseTestCase): ) -class ComponentRenderTest(BaseTestCase): - @parametrize_context_behavior(["django", "isolated"]) - def test_render_minimal(self): +@djc_test +class TestComponentRender: + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_render_minimal(self, components_settings): class SimpleComponent(Component): template: types.django_html = """ {% load component_tags %} @@ -783,7 +771,7 @@ class ComponentRenderTest(BaseTestCase): } rendered = SimpleComponent.render() - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ the_arg2: None @@ -797,8 +785,8 @@ class ComponentRenderTest(BaseTestCase): """, ) - @parametrize_context_behavior(["django", "isolated"]) - def test_render_full(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_render_full(self, components_settings): class SimpleComponent(Component): template: types.django_html = """ {% load component_tags %} @@ -833,7 +821,7 @@ class ComponentRenderTest(BaseTestCase): kwargs={"the_kwarg": "test", "kw2": "ooo"}, slots={"first": "FIRST_SLOT"}, ) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ the_arg: one @@ -850,8 +838,8 @@ class ComponentRenderTest(BaseTestCase): """, ) - @parametrize_context_behavior(["django", "isolated"]) - def test_render_to_response_full(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_render_to_response_full(self, components_settings): class SimpleComponent(Component): template: types.django_html = """ {% load component_tags %} @@ -886,9 +874,9 @@ class ComponentRenderTest(BaseTestCase): kwargs={"the_kwarg": "test", "kw2": "ooo"}, slots={"first": "FIRST_SLOT"}, ) - self.assertIsInstance(rendered, HttpResponse) + assert isinstance(rendered, HttpResponse) - self.assertHTMLEqual( + assertHTMLEqual( rendered.content.decode(), """ the_arg: one @@ -905,8 +893,8 @@ class ComponentRenderTest(BaseTestCase): """, ) - @parametrize_context_behavior(["django", "isolated"]) - def test_render_to_response_change_response_class(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_render_to_response_change_response_class(self, components_settings): class MyResponse: def __init__(self, content: str) -> None: self.content = bytes(content, "utf-8") @@ -916,17 +904,24 @@ class ComponentRenderTest(BaseTestCase): template: types.django_html = "HELLO" rendered = SimpleComponent.render_to_response() - self.assertIsInstance(rendered, MyResponse) + assert isinstance(rendered, MyResponse) - self.assertHTMLEqual( + assertHTMLEqual( rendered.content.decode(), "HELLO", ) - @parametrize_context_behavior([("django", False), ("isolated", True)]) - def test_render_slot_as_func(self, context_behavior_data): - is_isolated = context_behavior_data - + @djc_test( + parametrize=( + ["components_settings", "is_isolated"], + [ + [{"context_behavior": "django"}, False], + [{"context_behavior": "isolated"}, True], + ], + ["django", "isolated"], + ) + ) + def test_render_slot_as_func(self, components_settings, is_isolated): class SimpleComponent(Component): template: types.django_html = """ {% load component_tags %} @@ -943,30 +938,30 @@ class ComponentRenderTest(BaseTestCase): } def first_slot(ctx: Context, slot_data: Dict, slot_ref: SlotRef): - self.assertIsInstance(ctx, Context) + assert isinstance(ctx, Context) # NOTE: Since the slot has access to the Context object, it should behave # the same way as it does in templates - when in "isolated" mode, then the # slot fill has access only to the "root" context, but not to the data of # get_context_data() of SimpleComponent. if is_isolated: - self.assertEqual(ctx.get("the_arg"), None) - self.assertEqual(ctx.get("the_kwarg"), None) - self.assertEqual(ctx.get("kwargs"), None) - self.assertEqual(ctx.get("abc"), None) + assert ctx.get("the_arg") is None + assert ctx.get("the_kwarg") is None + assert ctx.get("kwargs") is None + assert ctx.get("abc") is None else: - self.assertEqual(ctx["the_arg"], "1") - self.assertEqual(ctx["the_kwarg"], 3) - self.assertEqual(ctx["kwargs"], {}) - self.assertEqual(ctx["abc"], "def") + assert ctx["the_arg"] == "1" + assert ctx["the_kwarg"] == 3 + assert ctx["kwargs"] == {} + assert ctx["abc"] == "def" slot_data_expected = { "data1": "abc", "data2": {"hello": "world", "one": 123}, } - self.assertDictEqual(slot_data_expected, slot_data) + assert slot_data_expected == slot_data - self.assertIsInstance(slot_ref, SlotRef) - self.assertEqual("SLOT_DEFAULT", str(slot_ref).strip()) + assert isinstance(slot_ref, SlotRef) + assert "SLOT_DEFAULT" == str(slot_ref).strip() return f"FROM_INSIDE_FIRST_SLOT | {slot_ref}" @@ -976,13 +971,13 @@ class ComponentRenderTest(BaseTestCase): kwargs={"the_kwarg": 3}, slots={"first": first_slot}, ) - self.assertHTMLEqual( + assertHTMLEqual( rendered, "FROM_INSIDE_FIRST_SLOT | SLOT_DEFAULT", ) - @parametrize_context_behavior(["django", "isolated"]) - def test_render_raises_on_missing_slot(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_render_raises_on_missing_slot(self, components_settings): class SimpleComponent(Component): template: types.django_html = """ {% load component_tags %} @@ -990,8 +985,11 @@ class ComponentRenderTest(BaseTestCase): {% endslot %} """ - with self.assertRaisesMessage( - TemplateSyntaxError, "Slot 'first' is marked as 'required' (i.e. non-optional), yet no fill is provided." + with pytest.raises( + TemplateSyntaxError, + match=re.escape( + "Slot 'first' is marked as 'required' (i.e. non-optional), yet no fill is provided." + ), ): SimpleComponent.render() @@ -999,8 +997,8 @@ class ComponentRenderTest(BaseTestCase): slots={"first": "FIRST_SLOT"}, ) - @parametrize_context_behavior(["django", "isolated"]) - def test_render_with_include(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_render_with_include(self, components_settings): class SimpleComponent(Component): template: types.django_html = """ {% load component_tags %} @@ -1008,7 +1006,7 @@ class ComponentRenderTest(BaseTestCase): """ rendered = SimpleComponent.render() - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ @@ -1021,8 +1019,8 @@ class ComponentRenderTest(BaseTestCase): # See https://github.com/django-components/django-components/issues/580 # And https://github.com/django-components/django-components/commit/fee26ec1d8b46b5ee065ca1ce6143889b0f96764 - @parametrize_context_behavior(["django", "isolated"]) - def test_render_with_include_and_context(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_render_with_include_and_context(self, components_settings): class SimpleComponent(Component): template: types.django_html = """ {% load component_tags %} @@ -1030,7 +1028,7 @@ class ComponentRenderTest(BaseTestCase): """ rendered = SimpleComponent.render(context=Context()) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ @@ -1044,8 +1042,8 @@ class ComponentRenderTest(BaseTestCase): # See https://github.com/django-components/django-components/issues/580 # And https://github.com/django-components/django-components/issues/634 # And https://github.com/django-components/django-components/commit/fee26ec1d8b46b5ee065ca1ce6143889b0f96764 - @parametrize_context_behavior(["django", "isolated"]) - def test_render_with_include_and_request_context(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_render_with_include_and_request_context(self, components_settings): class SimpleComponent(Component): template: types.django_html = """ {% load component_tags %} @@ -1053,7 +1051,7 @@ class ComponentRenderTest(BaseTestCase): """ rendered = SimpleComponent.render(context=RequestContext(HttpRequest())) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ @@ -1066,8 +1064,8 @@ class ComponentRenderTest(BaseTestCase): # See https://github.com/django-components/django-components/issues/580 # And https://github.com/django-components/django-components/issues/634 - @parametrize_context_behavior(["django", "isolated"]) - def test_request_context_is_populated_from_context_processors(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_request_context_is_populated_from_context_processors(self, components_settings): @register("thing") class Thing(Component): template: types.django_html = """ @@ -1092,7 +1090,7 @@ class ComponentRenderTest(BaseTestCase): client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())]) response = client.get("/test_thing/") - self.assertEqual(response.status_code, 200) + assert response.status_code == 200 # Full response: # """ @@ -1106,7 +1104,7 @@ class ComponentRenderTest(BaseTestCase): #
# # """ - self.assertInHTML( + assertInHTML( """ Rendered via GET request @@ -1118,7 +1116,7 @@ class ComponentRenderTest(BaseTestCase): token_re = re.compile(rb"CSRF token:\s+predictabletoken") token = token_re.findall(response.content)[0] - self.assertEqual(token, b'CSRF token: predictabletoken') + assert token == b"CSRF token: predictabletoken" def test_request_context_created_when_no_context(self): @register("thing") @@ -1133,12 +1131,12 @@ class ComponentRenderTest(BaseTestCase): client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())]) response = client.get("/test_thing/") - self.assertEqual(response.status_code, 200) + assert response.status_code == 200 token_re = re.compile(rb"CSRF token:\s+predictabletoken") token = token_re.findall(response.content)[0] - self.assertEqual(token, b'CSRF token: predictabletoken') + assert token == b"CSRF token: predictabletoken" def test_request_context_created_when_already_a_context_dict(self): @register("thing") @@ -1154,13 +1152,13 @@ class ComponentRenderTest(BaseTestCase): client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())]) response = client.get("/test_thing/") - self.assertEqual(response.status_code, 200) + assert response.status_code == 200 token_re = re.compile(rb"CSRF token:\s+predictabletoken") token = token_re.findall(response.content)[0] - self.assertEqual(token, b'CSRF token: predictabletoken') - self.assertInHTML("Existing context: foo", response.content.decode()) + assert token == b"CSRF token: predictabletoken" + assert "Existing context: foo" in response.content.decode() def request_context_ignores_context_when_already_a_context(self): @register("thing") @@ -1176,15 +1174,15 @@ class ComponentRenderTest(BaseTestCase): client = CustomClient(urlpatterns=[path("test_thing/", Thing.as_view())]) response = client.get("/test_thing/") - self.assertEqual(response.status_code, 200) + assert response.status_code == 200 token_re = re.compile(rb"CSRF token:\s+(?P[0-9a-zA-Z]{64})") - self.assertFalse(token_re.findall(response.content)) - self.assertInHTML("Existing context: foo", response.content.decode()) + assert not token_re.findall(response.content) + assert "Existing context: foo" in response.content.decode() - @parametrize_context_behavior(["django", "isolated"]) - def test_render_with_extends(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_render_with_extends(self, components_settings): class SimpleComponent(Component): template: types.django_html = """ {% extends 'block.html' %} @@ -1194,7 +1192,7 @@ class ComponentRenderTest(BaseTestCase): """ rendered = SimpleComponent.render(render_dependencies=False) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ @@ -1210,8 +1208,8 @@ class ComponentRenderTest(BaseTestCase): """, ) - @parametrize_context_behavior(["django", "isolated"]) - def test_render_can_access_instance(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_render_can_access_instance(self, components_settings): class TestComponent(Component): template = "Variable: {{ id }}" @@ -1221,13 +1219,13 @@ class ComponentRenderTest(BaseTestCase): } rendered = TestComponent.render() - self.assertHTMLEqual( + assertHTMLEqual( rendered, "Variable: a1bc3e", ) - @parametrize_context_behavior(["django", "isolated"]) - def test_render_to_response_can_access_instance(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_render_to_response_can_access_instance(self, components_settings): class TestComponent(Component): template = "Variable: {{ id }}" @@ -1237,13 +1235,14 @@ class ComponentRenderTest(BaseTestCase): } rendered_resp = TestComponent.render_to_response() - self.assertHTMLEqual( + assertHTMLEqual( rendered_resp.content.decode("utf-8"), "Variable: a1bc3e", ) -class ComponentHookTest(BaseTestCase): +@djc_test +class TestComponentHook: def test_on_render_before(self): @register("nested") class NestedComponent(Component): @@ -1285,7 +1284,7 @@ class ComponentHookTest(BaseTestCase): template.nodelist.append(TextNode("\n---\nFROM_ON_BEFORE")) rendered = SimpleComponent.render() - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ args: () @@ -1348,7 +1347,7 @@ class ComponentHookTest(BaseTestCase): rendered = SimpleComponent.render() - self.assertHTMLEqual( + assertHTMLEqual( captured_content, """ args: () @@ -1362,7 +1361,7 @@ class ComponentHookTest(BaseTestCase): """, ) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ args: () @@ -1378,8 +1377,8 @@ class ComponentHookTest(BaseTestCase): ) # Check that modifying the context or template does nothing - @parametrize_context_behavior(["django", "isolated"]) - def test_on_render_after_override_output(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_on_render_after_override_output(self, components_settings): captured_content = None @register("nested") @@ -1419,7 +1418,7 @@ class ComponentHookTest(BaseTestCase): rendered = SimpleComponent.render() - self.assertHTMLEqual( + assertHTMLEqual( captured_content, """ args: () @@ -1433,7 +1432,7 @@ class ComponentHookTest(BaseTestCase): """, ) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ Chocolate cookie recipe: @@ -1495,9 +1494,6 @@ class ComponentHookTest(BaseTestCase): SimpleComponent.render() - self.assertEqual( - context_in_before, - context_in_after, - ) - self.assertIn("from_on_before", context_in_before) - self.assertIn("from_on_after", context_in_after) + assert context_in_before == context_in_after + assert "from_on_before" in context_in_before # type: ignore[operator] + assert "from_on_after" in context_in_after # type: ignore[operator] diff --git a/tests/test_component_highlight.py b/tests/test_component_highlight.py index e117a6a1..cd8905e0 100644 --- a/tests/test_component_highlight.py +++ b/tests/test_component_highlight.py @@ -1,12 +1,13 @@ from django_components.util.component_highlight import apply_component_highlight, COLORS -from .django_test_setup import setup_test_config -from .testutils import BaseTestCase +from django_components.testing import djc_test +from .testutils import setup_test_config setup_test_config({"autodiscover": False}) -class ComponentHighlightTests(BaseTestCase): +@djc_test +class TestComponentHighlight: def test_component_highlight(self): # Test component highlighting test_html = "
Test content
" @@ -14,12 +15,12 @@ class ComponentHighlightTests(BaseTestCase): result = apply_component_highlight("component", test_html, component_name) # Check that the output contains the component name - self.assertIn(component_name, result) + assert component_name in result # Check that the output contains the original HTML - self.assertIn(test_html, result) + assert test_html in result # Check that the component colors are used - self.assertIn(COLORS["component"].text_color, result) - self.assertIn(COLORS["component"].border_color, result) + assert COLORS["component"].text_color in result + assert COLORS["component"].border_color in result def test_slot_highlight(self): # Test slot highlighting @@ -28,9 +29,9 @@ class ComponentHighlightTests(BaseTestCase): result = apply_component_highlight("slot", test_html, slot_name) # Check that the output contains the slot name - self.assertIn(slot_name, result) + assert slot_name in result # Check that the output contains the original HTML - self.assertIn(test_html, result) + assert test_html in result # Check that the slot colors are used - self.assertIn(COLORS["slot"].text_color, result) - self.assertIn(COLORS["slot"].border_color, result) + assert COLORS["slot"].text_color in result + assert COLORS["slot"].border_color in result diff --git a/tests/test_component_media.py b/tests/test_component_media.py index 24201f4f..6fb2caa1 100644 --- a/tests/test_component_media.py +++ b/tests/test_component_media.py @@ -1,83 +1,72 @@ import os import sys from pathlib import Path +from textwrap import dedent from django.forms.widgets import Media from django.template import Context, Template from django.templatetags.static import static -from django.test import override_settings from django.utils.html import format_html, html_safe from django.utils.safestring import mark_safe +from pytest_django.asserts import assertHTMLEqual, assertInHTML -from django_components import Component, registry, render_dependencies, types +from django_components import Component, autodiscover, registry, render_dependencies, types -from .django_test_setup import setup_test_config -from .testutils import BaseTestCase, autodiscover_with_cleanup +from django_components.testing import djc_test +from .testutils import setup_test_config setup_test_config({"autodiscover": False}) # "Main media" refer to the HTML, JS, and CSS set on the Component class itself # (as opposed via the `Media` class). These have special handling in the Component. -class MainMediaTest(BaseTestCase): +@djc_test +class TestMainMedia: def test_html_js_css_inlined(self): class TestComponent(Component): - template = """ + template = dedent(""" {% load component_tags %} {% component_js_dependencies %} {% component_css_dependencies %}
Content
- """ + """) css = ".html-css-only { color: blue; }" js = "console.log('HTML and JS only');" - self.assertEqual( - TestComponent.css, - ".html-css-only { color: blue; }", - ) - self.assertEqual( - TestComponent.js, - "console.log('HTML and JS only');", - ) + assert TestComponent.css == ".html-css-only { color: blue; }" + assert TestComponent.js == "console.log('HTML and JS only');" rendered = TestComponent.render() - self.assertInHTML( + assertInHTML( '
Content
', rendered, ) - self.assertInHTML( + assertInHTML( "", rendered, ) - self.assertInHTML( + assertInHTML( "", rendered, ) # Check that the HTML / JS / CSS can be accessed on the component class - self.assertEqual( - TestComponent.template, - """ - {% load component_tags %} - {% component_js_dependencies %} - {% component_css_dependencies %} -
Content
- """, - ) - self.assertEqual( - TestComponent.css, - ".html-css-only { color: blue; }", - ) - self.assertEqual( - TestComponent.js, - "console.log('HTML and JS only');", - ) + assert TestComponent.template == dedent(""" + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} +
Content
+ """) + assert TestComponent.css == ".html-css-only { color: blue; }" + assert TestComponent.js == "console.log('HTML and JS only');" - @override_settings( - STATICFILES_DIRS=[ - os.path.join(Path(__file__).resolve().parent, "static_root"), - ], + @djc_test( + django_settings={ + "STATICFILES_DIRS": [ + os.path.join(Path(__file__).resolve().parent, "static_root"), + ], + } ) def test_html_js_css_filepath_rel_to_component(self): from tests.test_app.components.app_lvl_comp.app_lvl_comp import AppLvlCompComponent @@ -87,14 +76,8 @@ class MainMediaTest(BaseTestCase): registry.register("test", TestComponent) - self.assertIn( - ".html-css-only {\n color: blue;\n}", - TestComponent.css, - ) - self.assertIn( - 'console.log("JS file");', - TestComponent.js, - ) + assert ".html-css-only {\n color: blue;\n}" in TestComponent.css # type: ignore[operator] + assert 'console.log("JS file");' in TestComponent.js # type: ignore[operator] rendered_raw = Template( """ @@ -106,7 +89,7 @@ class MainMediaTest(BaseTestCase): ).render(Context()) rendered = render_dependencies(rendered_raw) - self.assertInHTML( + assertInHTML( """
@@ -115,37 +98,33 @@ class MainMediaTest(BaseTestCase): """, rendered, ) - self.assertInHTML( + assertInHTML( "", rendered, ) - self.assertInHTML( + assertInHTML( '', rendered, ) # Check that the HTML / JS / CSS can be accessed on the component class - self.assertEqual( - TestComponent.template, - ( - '\n' - " {% csrf_token %}\n" - ' \n' - ' \n' - "
\n" - ), + assert TestComponent.template == ( + '
\n' + " {% csrf_token %}\n" + ' \n' + ' \n' + "
\n" ) - self.assertEqual(TestComponent.css, ".html-css-only {\n" " color: blue;\n" "}\n") - self.assertEqual( - TestComponent.js, - 'console.log("JS file");\n', - ) + assert TestComponent.css == ".html-css-only {\n color: blue;\n}\n" + assert TestComponent.js == 'console.log("JS file");\n' - @override_settings( - STATICFILES_DIRS=[ - os.path.join(Path(__file__).resolve().parent, "static_root"), - ], + @djc_test( + django_settings={ + "STATICFILES_DIRS": [ + os.path.join(Path(__file__).resolve().parent, "static_root"), + ], + } ) def test_html_js_css_filepath_from_static(self): class TestComponent(Component): @@ -160,18 +139,9 @@ class MainMediaTest(BaseTestCase): registry.register("test", TestComponent) - self.assertIn( - "Variable: {{ variable }}", - TestComponent.template, - ) - self.assertIn( - ".html-css-only {\n color: blue;\n}", - TestComponent.css, - ) - self.assertIn( - 'console.log("HTML and JS only");', - TestComponent.js, - ) + assert "Variable: {{ variable }}" in TestComponent.template # type: ignore[operator] + assert ".html-css-only {\n color: blue;\n}" in TestComponent.css # type: ignore[operator] + assert 'console.log("HTML and JS only");' in TestComponent.js # type: ignore[operator] rendered_raw = Template( """ @@ -183,45 +153,35 @@ class MainMediaTest(BaseTestCase): ).render(Context()) rendered = render_dependencies(rendered_raw) - self.assertIn( - 'Variable: test', - rendered, - ) - self.assertInHTML( + assert 'Variable: test' in rendered + assertInHTML( "", rendered, ) - self.assertInHTML( + assertInHTML( '', rendered, ) # Check that the HTML / JS / CSS can be accessed on the component class - self.assertEqual( - TestComponent.template, - "Variable: {{ variable }}\n", + assert TestComponent.template == "Variable: {{ variable }}\n" + assert TestComponent.css == ( + "/* Used in `MainMediaTest` tests in `test_component_media.py` */\n" + ".html-css-only {\n" + " color: blue;\n" + "}" ) - self.assertEqual( - TestComponent.css, - ( - "/* Used in `MainMediaTest` tests in `test_component_media.py` */\n" - ".html-css-only {\n" - " color: blue;\n" - "}" - ), - ) - self.assertEqual( - TestComponent.js, - ( - "/* Used in `MainMediaTest` tests in `test_component_media.py` */\n" - 'console.log("HTML and JS only");\n' - ), + assert TestComponent.js == ( + "/* Used in `MainMediaTest` tests in `test_component_media.py` */\n" + 'console.log("HTML and JS only");\n' ) - @override_settings( - STATICFILES_DIRS=[ - os.path.join(Path(__file__).resolve().parent, "static_root"), - ], + @djc_test( + django_settings={ + "STATICFILES_DIRS": [ + os.path.join(Path(__file__).resolve().parent, "static_root"), + ], + } ) def test_html_js_css_filepath_lazy_loaded(self): from tests.test_app.components.app_lvl_comp.app_lvl_comp import AppLvlCompComponent @@ -231,51 +191,26 @@ class MainMediaTest(BaseTestCase): # NOTE: Since this is a subclass, actual CSS is defined on the parent class, and thus # the corresponding ComponentMedia instance is also on the parent class. - self.assertEqual( - AppLvlCompComponent._component_media.css, # type: ignore[attr-defined] - None, - ) - self.assertEqual( - AppLvlCompComponent._component_media.css_file, # type: ignore[attr-defined] - "app_lvl_comp.css", - ) + assert AppLvlCompComponent._component_media.css is None # type: ignore[attr-defined] + assert AppLvlCompComponent._component_media.css_file == "app_lvl_comp.css" # type: ignore[attr-defined] # Access the property to load the CSS _ = TestComponent.css - self.assertEqual( - AppLvlCompComponent._component_media.css, # type: ignore[attr-defined] - (".html-css-only {\n" " color: blue;\n" "}\n"), - ) - self.assertEqual( - AppLvlCompComponent._component_media.css_file, # type: ignore[attr-defined] - "app_lvl_comp/app_lvl_comp.css", - ) + assert AppLvlCompComponent._component_media.css == (".html-css-only {\n" " color: blue;\n" "}\n") # type: ignore[attr-defined] + assert AppLvlCompComponent._component_media.css_file == "app_lvl_comp/app_lvl_comp.css" # type: ignore[attr-defined] # Also check JS and HTML while we're at it - self.assertEqual( - AppLvlCompComponent._component_media.template, # type: ignore[attr-defined] - ( - '
\n' - " {% csrf_token %}\n" - ' \n' - ' \n' - "
\n" - ), - ) - self.assertEqual( - AppLvlCompComponent._component_media.template_file, # type: ignore[attr-defined] - "app_lvl_comp/app_lvl_comp.html", - ) - - self.assertEqual( - AppLvlCompComponent._component_media.js, # type: ignore[attr-defined] - 'console.log("JS file");\n', - ) - self.assertEqual( - AppLvlCompComponent._component_media.js_file, # type: ignore[attr-defined] - "app_lvl_comp/app_lvl_comp.js", + assert AppLvlCompComponent._component_media.template == ( # type: ignore[attr-defined] + '
\n' + " {% csrf_token %}\n" + ' \n' + ' \n' + "
\n" ) + assert AppLvlCompComponent._component_media.template_file == "app_lvl_comp/app_lvl_comp.html" # type: ignore[attr-defined] + assert AppLvlCompComponent._component_media.js == 'console.log("JS file");\n' # type: ignore[attr-defined] + assert AppLvlCompComponent._component_media.js_file == "app_lvl_comp/app_lvl_comp.js" # type: ignore[attr-defined] def test_html_variable(self): class VariableHTMLComponent(Component): @@ -284,7 +219,7 @@ class MainMediaTest(BaseTestCase): comp = VariableHTMLComponent("variable_html_component") context = Context({"variable": "Dynamic Content"}) - self.assertHTMLEqual( + assertHTMLEqual( comp.render(context), '
Dynamic Content
', ) @@ -303,7 +238,7 @@ class MainMediaTest(BaseTestCase): } rendered = FilteredComponent.render(kwargs={"var1": "test1", "var2": "test2"}) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """ Var1: test1 @@ -312,7 +247,8 @@ class MainMediaTest(BaseTestCase): ) -class ComponentMediaTests(BaseTestCase): +@djc_test +class TestComponentMedia: def test_empty_media(self): class SimpleComponent(Component): template: types.django_html = """ @@ -327,10 +263,10 @@ class ComponentMediaTests(BaseTestCase): rendered = SimpleComponent.render() - self.assertEqual(rendered.count("', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) def test_css_js_as_string(self): class SimpleComponent(Component): @@ -365,8 +301,8 @@ class ComponentMediaTests(BaseTestCase): rendered = SimpleComponent.render() - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) def test_css_as_dict(self): class SimpleComponent(Component): @@ -386,11 +322,11 @@ class ComponentMediaTests(BaseTestCase): rendered = SimpleComponent.render() - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) def test_media_custom_render_js(self): class MyMedia(Media): @@ -415,8 +351,8 @@ class ComponentMediaTests(BaseTestCase): rendered = SimpleComponent.render() - self.assertIn('', rendered) - self.assertIn('', rendered) + assert '' in rendered + assert '' in rendered def test_media_custom_render_css(self): class MyMedia(Media): @@ -446,12 +382,13 @@ class ComponentMediaTests(BaseTestCase): rendered = SimpleComponent.render() - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) -class MediaPathAsObjectTests(BaseTestCase): +@djc_test +class TestMediaPathAsObject: def test_safestring(self): """ Test that media work with paths defined as instances of classes that define @@ -513,15 +450,15 @@ class MediaPathAsObjectTests(BaseTestCase): rendered = SimpleComponent.render() - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) def test_pathlike(self): """ @@ -562,14 +499,14 @@ class MediaPathAsObjectTests(BaseTestCase): rendered = SimpleComponent.render() - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) def test_str(self): """ @@ -605,13 +542,13 @@ class MediaPathAsObjectTests(BaseTestCase): rendered = SimpleComponent.render() - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) def test_bytes(self): """ @@ -647,13 +584,13 @@ class MediaPathAsObjectTests(BaseTestCase): rendered = SimpleComponent.render() - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) def test_function(self): class SimpleComponent(Component): @@ -679,17 +616,21 @@ class MediaPathAsObjectTests(BaseTestCase): rendered = SimpleComponent.render() - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - @override_settings(STATIC_URL="static/") + @djc_test( + django_settings={ + "STATIC_URL": "static/", + } + ) def test_works_with_static(self): """Test that all the different ways of defining media files works with Django's staticfiles""" @@ -718,32 +659,35 @@ class MediaPathAsObjectTests(BaseTestCase): rendered = SimpleComponent.render() - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) -class MediaStaticfilesTests(BaseTestCase): +@djc_test +class TestMediaStaticfiles: # For context see https://github.com/django-components/django-components/issues/522 - @override_settings( - # Configure static files. The dummy files are set up in the `./static_root` dir. - # The URL should have path prefix /static/. - # NOTE: We don't need STATICFILES_DIRS, because we don't run collectstatic - # See https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATICFILES_DIRS - STATIC_URL="static/", - STATIC_ROOT=os.path.join(Path(__file__).resolve().parent, "static_root"), - # `django.contrib.staticfiles` MUST be installed for staticfiles resolution to work. - INSTALLED_APPS=[ - "django.contrib.staticfiles", - "django_components", - ], + @djc_test( + django_settings={ + # Configure static files. The dummy files are set up in the `./static_root` dir. + # The URL should have path prefix /static/. + # NOTE: We don't need STATICFILES_DIRS, because we don't run collectstatic + # See https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATICFILES_DIRS + "STATIC_URL": "static/", + "STATIC_ROOT": os.path.join(Path(__file__).resolve().parent, "static_root"), + # `django.contrib.staticfiles` MUST be installed for staticfiles resolution to work. + "INSTALLED_APPS": [ + "django.contrib.staticfiles", + "django_components", + ], + } ) def test_default_static_files_storage(self): """Test integration with Django's staticfiles app""" @@ -773,35 +717,37 @@ class MediaStaticfilesTests(BaseTestCase): # NOTE: Since we're using the default storage class for staticfiles, the files should # be searched as specified above (e.g. `calendar/script.js`) inside `static_root` dir. - self.assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) # For context see https://github.com/django-components/django-components/issues/522 - @override_settings( - # Configure static files. The dummy files are set up in the `./static_root` dir. - # The URL should have path prefix /static/. - # NOTE: We don't need STATICFILES_DIRS, because we don't run collectstatic - # See https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATICFILES_DIRS - STATIC_URL="static/", - STATIC_ROOT=os.path.join(Path(__file__).resolve().parent, "static_root"), - # NOTE: STATICFILES_STORAGE is deprecated since 5.1, use STORAGES instead - # See https://docs.djangoproject.com/en/5.0/ref/settings/#staticfiles-storage - STORAGES={ - # This was NOT changed - "default": { - "BACKEND": "django.core.files.storage.FileSystemStorage", + @djc_test( + django_settings={ + # Configure static files. The dummy files are set up in the `./static_root` dir. + # The URL should have path prefix /static/. + # NOTE: We don't need STATICFILES_DIRS, because we don't run collectstatic + # See https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-STATICFILES_DIRS + "STATIC_URL": "static/", + "STATIC_ROOT": os.path.join(Path(__file__).resolve().parent, "static_root"), + # NOTE: STATICFILES_STORAGE is deprecated since 5.1, use STORAGES instead + # See https://docs.djangoproject.com/en/5.0/ref/settings/#staticfiles-storage + "STORAGES": { + # This was NOT changed + "default": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + }, + # This WAS changed so that static files are looked up by the `staticfiles.json` + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage", + }, }, - # This WAS changed so that static files are looked up by the `staticfiles.json` - "staticfiles": { - "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage", - }, - }, - # `django.contrib.staticfiles` MUST be installed for staticfiles resolution to work. - INSTALLED_APPS=[ - "django.contrib.staticfiles", - "django_components", - ], + # `django.contrib.staticfiles` MUST be installed for staticfiles resolution to work. + "INSTALLED_APPS": [ + "django.contrib.staticfiles", + "django_components", + ], + } ) def test_manifest_static_files_storage(self): """Test integration with Django's staticfiles app and ManifestStaticFilesStorage""" @@ -831,14 +777,15 @@ class MediaStaticfilesTests(BaseTestCase): # NOTE: Since we're using ManifestStaticFilesStorage, we expect the rendered media to link # to the files as defined in staticfiles.json - self.assertInHTML( + assertInHTML( '', rendered ) - self.assertInHTML('', rendered) + assertInHTML('', rendered) -class MediaRelativePathTests(BaseTestCase): +@djc_test +class TestMediaRelativePath: class ParentComponent(Component): template: types.django_html = """ {% load component_tags %} @@ -874,94 +821,103 @@ class MediaRelativePathTests(BaseTestCase): context["unique_variable"] = new_variable return context - def setUp(self): - super().setUp() + # Settings required for autodiscover to work + @djc_test( + django_settings={ + "BASE_DIR": Path(__file__).resolve().parent, + "STATICFILES_DIRS": [ + Path(__file__).resolve().parent / "components", + ], + } + ) + def test_component_with_relative_media_paths(self): registry.register(name="parent_component", component=self.ParentComponent) registry.register(name="variable_display", component=self.VariableDisplay) - # Settings required for autodiscover to work - @override_settings( - BASE_DIR=Path(__file__).resolve().parent, - STATICFILES_DIRS=[ - Path(__file__).resolve().parent / "components", - ], - ) - def test_component_with_relative_media_paths(self): # Ensure that the module is executed again after import in autodiscovery if "tests.components.relative_file.relative_file" in sys.modules: del sys.modules["tests.components.relative_file.relative_file"] # Fix the paths, since the "components" dir is nested - with autodiscover_with_cleanup(map_module=lambda p: f"tests.{p}" if p.startswith("components") else p): - # Make sure that only relevant components are registered: - comps_to_remove = [ - comp_name - for comp_name in registry.all() - if comp_name not in ["relative_file_component", "parent_component", "variable_display"] - ] - for comp_name in comps_to_remove: - registry.unregister(comp_name) + autodiscover(map_module=lambda p: f"tests.{p}" if p.startswith("components") else p) - template_str: types.django_html = """ - {% load component_tags %} - {% component_js_dependencies %} - {% component_css_dependencies %} - {% component name='relative_file_component' variable=variable / %} + # Make sure that only relevant components are registered: + comps_to_remove = [ + comp_name + for comp_name in registry.all() + if comp_name not in ["relative_file_component", "parent_component", "variable_display"] + ] + for comp_name in comps_to_remove: + registry.unregister(comp_name) + + template_str: types.django_html = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + {% component name='relative_file_component' variable=variable / %} + """ + template = Template(template_str) + rendered = render_dependencies(template.render(Context({"variable": "test"}))) + + assertInHTML('', rendered) + + assertInHTML( """ - template = Template(template_str) - rendered = render_dependencies(template.render(Context({"variable": "test"}))) +
+ + +
+ """, + rendered, + ) - self.assertInHTML('', rendered) - - self.assertInHTML( - """ -
- - -
- """, - rendered, - ) - - self.assertInHTML('', rendered) + assertInHTML('', rendered) # Settings required for autodiscover to work - @override_settings( - BASE_DIR=Path(__file__).resolve().parent, - STATICFILES_DIRS=[ - Path(__file__).resolve().parent / "components", - ], + @djc_test( + django_settings={ + "BASE_DIR": Path(__file__).resolve().parent, + "STATICFILES_DIRS": [ + Path(__file__).resolve().parent / "components", + ], + } ) def test_component_with_relative_media_paths_as_subcomponent(self): + registry.register(name="parent_component", component=self.ParentComponent) + registry.register(name="variable_display", component=self.VariableDisplay) + # Ensure that the module is executed again after import in autodiscovery if "tests.components.relative_file.relative_file" in sys.modules: del sys.modules["tests.components.relative_file.relative_file"] # Fix the paths, since the "components" dir is nested - with autodiscover_with_cleanup(map_module=lambda p: f"tests.{p}" if p.startswith("components") else p): - registry.unregister("relative_file_pathobj_component") + autodiscover(map_module=lambda p: f"tests.{p}" if p.startswith("components") else p) - template_str: types.django_html = """ - {% load component_tags %} - {% component_js_dependencies %} - {% component_css_dependencies %} - {% component 'parent_component' %} - {% fill 'content' %} - {% component name='relative_file_component' variable='hello' %} - {% endcomponent %} - {% endfill %} - {% endcomponent %} - """ - template = Template(template_str) - rendered = template.render(Context({})) - self.assertInHTML('', rendered) + registry.unregister("relative_file_pathobj_component") + + template_str: types.django_html = """ + {% load component_tags %} + {% component_js_dependencies %} + {% component_css_dependencies %} + {% component 'parent_component' %} + {% fill 'content' %} + {% component name='relative_file_component' variable='hello' %} + {% endcomponent %} + {% endfill %} + {% endcomponent %} + """ + template = Template(template_str) + rendered = template.render(Context({})) + assertInHTML('', rendered) # Settings required for autodiscover to work - @override_settings( - BASE_DIR=Path(__file__).resolve().parent, - STATICFILES_DIRS=[ - Path(__file__).resolve().parent / "components", - ], + @djc_test( + django_settings={ + "BASE_DIR": Path(__file__).resolve().parent, + "STATICFILES_DIRS": [ + Path(__file__).resolve().parent / "components", + ], + } ) def test_component_with_relative_media_does_not_trigger_safestring_path_at__new__(self): """ @@ -974,28 +930,32 @@ class MediaRelativePathTests(BaseTestCase): https://github.com/django-components/django-components/issues/522#issuecomment-2173577094 """ + registry.register(name="parent_component", component=self.ParentComponent) + registry.register(name="variable_display", component=self.VariableDisplay) # Ensure that the module is executed again after import in autodiscovery if "tests.components.relative_file_pathobj.relative_file_pathobj" in sys.modules: del sys.modules["tests.components.relative_file_pathobj.relative_file_pathobj"] # Fix the paths, since the "components" dir is nested - with autodiscover_with_cleanup(map_module=lambda p: f"tests.{p}" if p.startswith("components") else p): - # Mark the PathObj instances of 'relative_file_pathobj_component' so they won't raise - # error if PathObj.__str__ is triggered. - CompCls = registry.get("relative_file_pathobj_component") - CompCls.Media.js[0].throw_on_calling_str = False # type: ignore - CompCls.Media.css["all"][0].throw_on_calling_str = False # type: ignore + autodiscover(map_module=lambda p: f"tests.{p}" if p.startswith("components") else p) - rendered = CompCls.render(kwargs={"variable": "abc"}) + # Mark the PathObj instances of 'relative_file_pathobj_component' so they won't raise + # error if PathObj.__str__ is triggered. + CompCls = registry.get("relative_file_pathobj_component") + CompCls.Media.js[0].throw_on_calling_str = False # type: ignore + CompCls.Media.css["all"][0].throw_on_calling_str = False # type: ignore - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + rendered = CompCls.render(kwargs={"variable": "abc"}) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + + assertInHTML('', rendered) -class SubclassingMediaTests(BaseTestCase): +@djc_test +class TestSubclassingMedia: def test_media_in_child_and_parent(self): class ParentComponent(Component): template: types.django_html = """ @@ -1015,11 +975,11 @@ class SubclassingMediaTests(BaseTestCase): rendered = ChildComponent.render() - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) def test_media_in_child_and_grandparent(self): class GrandParentComponent(Component): @@ -1043,11 +1003,11 @@ class SubclassingMediaTests(BaseTestCase): rendered = ChildComponent.render() - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) def test_media_in_parent_and_grandparent(self): class GrandParentComponent(Component): @@ -1071,11 +1031,11 @@ class SubclassingMediaTests(BaseTestCase): rendered = ChildComponent.render() - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) def test_media_in_multiple_bases(self): class GrandParent1Component(Component): @@ -1118,15 +1078,15 @@ class SubclassingMediaTests(BaseTestCase): rendered = ChildComponent.render() - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) def test_extend_false_in_child(self): class Parent1Component(Component): @@ -1153,13 +1113,13 @@ class SubclassingMediaTests(BaseTestCase): rendered = ChildComponent.render() - self.assertNotIn("parent1.css", rendered) - self.assertNotIn("parent2.css", rendered) - self.assertInHTML('', rendered) + assert "parent1.css" not in rendered + assert "parent2.css" not in rendered + assertInHTML('', rendered) - self.assertNotIn("parent1.js", rendered) - self.assertNotIn("parent2.js", rendered) - self.assertInHTML('', rendered) + assert "parent1.js" not in rendered + assert "parent2.js" not in rendered + assertInHTML('', rendered) def test_extend_false_in_parent(self): class GrandParentComponent(Component): @@ -1191,15 +1151,15 @@ class SubclassingMediaTests(BaseTestCase): rendered = ChildComponent.render() - self.assertNotIn("grandparent.css", rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assert "grandparent.css" not in rendered + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertNotIn("grandparent.js", rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assert "grandparent.js" not in rendered + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) def test_extend_list_in_child(self): class Parent1Component(Component): @@ -1236,17 +1196,17 @@ class SubclassingMediaTests(BaseTestCase): rendered = ChildComponent.render() - self.assertNotIn("parent1.css", rendered) - self.assertNotIn("parent2.css", rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assert "parent1.css" not in rendered + assert "parent2.css" not in rendered + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertNotIn("parent1.js", rendered) - self.assertNotIn("parent2.js", rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assert "parent1.js" not in rendered + assert "parent2.js" not in rendered + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) def test_extend_list_in_parent(self): class Other1Component(Component): @@ -1288,16 +1248,16 @@ class SubclassingMediaTests(BaseTestCase): rendered = ChildComponent.render() - self.assertNotIn("grandparent.css", rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assert "grandparent.css" not in rendered + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) - self.assertNotIn("grandparent.js", rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) - self.assertInHTML('', rendered) + assert "grandparent.js" not in rendered + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) + assertInHTML('', rendered) diff --git a/tests/test_context.py b/tests/test_context.py index e2f3aae0..ca10f7df 100644 --- a/tests/test_context.py +++ b/tests/test_context.py @@ -1,12 +1,15 @@ +import re from typing import Dict, Optional +import pytest from django.http import HttpRequest from django.template import Context, RequestContext, Template +from pytest_django.asserts import assertHTMLEqual, assertInHTML from django_components import Component, register, registry, types -from .django_test_setup import setup_test_config -from .testutils import BaseTestCase, parametrize_context_behavior +from django_components.testing import djc_test +from .testutils import PARAMETRIZE_CONTEXT_BEHAVIOR, setup_test_config setup_test_config({"autodiscover": False}) @@ -66,7 +69,8 @@ class IncrementerComponent(Component): ######################### -class ContextTests(BaseTestCase): +@djc_test +class TestContext: class ParentComponent(Component): template: types.django_html = """ {% load component_tags %} @@ -87,15 +91,13 @@ class ContextTests(BaseTestCase): def get_context_data(self): return {"shadowing_variable": "NOT SHADOWED"} - def setUp(self): - super().setUp() + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_nested_component_context_shadows_parent_with_unfilled_slots_and_component_tag( + self, components_settings, + ): registry.register(name="variable_display", component=VariableDisplay) registry.register(name="parent_component", component=self.ParentComponent) - @parametrize_context_behavior(["django", "isolated"]) - def test_nested_component_context_shadows_parent_with_unfilled_slots_and_component_tag( - self, - ): template_str: types.django_html = """ {% load component_tags %} {% component 'parent_component' %}{% endcomponent %} @@ -103,14 +105,17 @@ class ContextTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context()) - self.assertInHTML("

Shadowing variable = override

", rendered) - self.assertInHTML("

Shadowing variable = slot_default_override

", rendered) - self.assertNotIn("Shadowing variable = NOT SHADOWED", rendered) + assertInHTML("

Shadowing variable = override

", rendered) + assertInHTML("

Shadowing variable = slot_default_override

", rendered) + assert "Shadowing variable = NOT SHADOWED" not in rendered - @parametrize_context_behavior(["django", "isolated"]) + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) def test_nested_component_instances_have_unique_context_with_unfilled_slots_and_component_tag( - self, + self, components_settings, ): + registry.register(name="variable_display", component=VariableDisplay) + registry.register(name="parent_component", component=self.ParentComponent) + template_str: types.django_html = """ {% load component_tags %} {% component name='parent_component' %}{% endcomponent %} @@ -118,14 +123,17 @@ class ContextTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context()) - self.assertInHTML("

Uniquely named variable = unique_val

", rendered) - self.assertInHTML( + assertInHTML("

Uniquely named variable = unique_val

", rendered) + assertInHTML( "

Uniquely named variable = slot_default_unique

", rendered, ) - @parametrize_context_behavior(["django", "isolated"]) - def test_nested_component_context_shadows_parent_with_filled_slots(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_nested_component_context_shadows_parent_with_filled_slots(self, components_settings): + registry.register(name="variable_display", component=VariableDisplay) + registry.register(name="parent_component", component=self.ParentComponent) + template_str: types.django_html = """ {% load component_tags %} {% component 'parent_component' %} @@ -138,12 +146,15 @@ class ContextTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context()) - self.assertInHTML("

Shadowing variable = override

", rendered) - self.assertInHTML("

Shadowing variable = shadow_from_slot

", rendered) - self.assertNotIn("Shadowing variable = NOT SHADOWED", rendered) + assertInHTML("

Shadowing variable = override

", rendered) + assertInHTML("

Shadowing variable = shadow_from_slot

", rendered) + assert "Shadowing variable = NOT SHADOWED" not in rendered + + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_nested_component_instances_have_unique_context_with_filled_slots(self, components_settings): + registry.register(name="variable_display", component=VariableDisplay) + registry.register(name="parent_component", component=self.ParentComponent) - @parametrize_context_behavior(["django", "isolated"]) - def test_nested_component_instances_have_unique_context_with_filled_slots(self): template_str: types.django_html = """ {% load component_tags %} {% component 'parent_component' %} @@ -156,13 +167,16 @@ class ContextTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context()) - self.assertInHTML("

Uniquely named variable = unique_val

", rendered) - self.assertInHTML("

Uniquely named variable = unique_from_slot

", rendered) + assertInHTML("

Uniquely named variable = unique_val

", rendered) + assertInHTML("

Uniquely named variable = unique_from_slot

", rendered) - @parametrize_context_behavior(["django", "isolated"]) + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) def test_nested_component_context_shadows_outer_context_with_unfilled_slots_and_component_tag( - self, + self, components_settings, ): + registry.register(name="variable_display", component=VariableDisplay) + registry.register(name="parent_component", component=self.ParentComponent) + template_str: types.django_html = """ {% load component_tags %} {% component name='parent_component' %}{% endcomponent %} @@ -170,14 +184,17 @@ class ContextTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context({"shadowing_variable": "NOT SHADOWED"})) - self.assertInHTML("

Shadowing variable = override

", rendered) - self.assertInHTML("

Shadowing variable = slot_default_override

", rendered) - self.assertNotIn("Shadowing variable = NOT SHADOWED", rendered) + assertInHTML("

Shadowing variable = override

", rendered) + assertInHTML("

Shadowing variable = slot_default_override

", rendered) + assert "Shadowing variable = NOT SHADOWED" not in rendered - @parametrize_context_behavior(["django", "isolated"]) + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) def test_nested_component_context_shadows_outer_context_with_filled_slots( - self, + self, components_settings, ): + registry.register(name="variable_display", component=VariableDisplay) + registry.register(name="parent_component", component=self.ParentComponent) + template_str: types.django_html = """ {% load component_tags %} {% component 'parent_component' %} @@ -190,12 +207,13 @@ class ContextTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context({"shadowing_variable": "NOT SHADOWED"})) - self.assertInHTML("

Shadowing variable = override

", rendered) - self.assertInHTML("

Shadowing variable = shadow_from_slot

", rendered) - self.assertNotIn("Shadowing variable = NOT SHADOWED", rendered) + assertInHTML("

Shadowing variable = override

", rendered) + assertInHTML("

Shadowing variable = shadow_from_slot

", rendered) + assert "Shadowing variable = NOT SHADOWED" not in rendered -class ParentArgsTests(BaseTestCase): +@djc_test +class TestParentArgs: class ParentComponentWithArgs(Component): template: types.django_html = """ {% load component_tags %} @@ -216,14 +234,12 @@ class ParentArgsTests(BaseTestCase): def get_context_data(self, parent_value): return {"inner_parent_value": parent_value} - def setUp(self): - super().setUp() + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_parent_args_can_be_drawn_from_context(self, components_settings): registry.register(name="incrementer", component=IncrementerComponent) registry.register(name="parent_with_args", component=self.ParentComponentWithArgs) registry.register(name="variable_display", component=VariableDisplay) - @parametrize_context_behavior(["django", "isolated"]) - def test_parent_args_can_be_drawn_from_context(self): template_str: types.django_html = """ {% load component_tags %} {% component 'parent_with_args' parent_value=parent_value %} @@ -232,7 +248,7 @@ class ParentArgsTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context({"parent_value": "passed_in"})) - self.assertHTMLEqual( + assertHTMLEqual( rendered, """
@@ -248,8 +264,12 @@ class ParentArgsTests(BaseTestCase): """, ) - @parametrize_context_behavior(["django", "isolated"]) - def test_parent_args_available_outside_slots(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_parent_args_available_outside_slots(self, components_settings): + registry.register(name="incrementer", component=IncrementerComponent) + registry.register(name="parent_with_args", component=self.ParentComponentWithArgs) + registry.register(name="variable_display", component=VariableDisplay) + template_str: types.django_html = """ {% load component_tags %} {% component 'parent_with_args' parent_value='passed_in' %}{%endcomponent %} @@ -257,19 +277,24 @@ class ParentArgsTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context()) - self.assertInHTML("

Shadowing variable = passed_in

", rendered) - self.assertInHTML("

Uniquely named variable = passed_in

", rendered) - self.assertNotIn("Shadowing variable = NOT SHADOWED", rendered) + assertInHTML("

Shadowing variable = passed_in

", rendered) + assertInHTML("

Uniquely named variable = passed_in

", rendered) + assert "Shadowing variable = NOT SHADOWED" not in rendered - # NOTE: Second arg in tuple are expected values passed through components. - @parametrize_context_behavior( - [ - ("django", ("passed_in", "passed_in")), - ("isolated", ("passed_in", "")), - ] + @djc_test( + parametrize=( + ["components_settings", "first_val", "second_val"], + [ + [{"context_behavior": "django"}, "passed_in", "passed_in"], + [{"context_behavior": "isolated"}, "passed_in", ""], + ], + ["django", "isolated"], + ) ) - def test_parent_args_available_in_slots(self, context_behavior_data): - first_val, second_val = context_behavior_data + def test_parent_args_available_in_slots(self, components_settings, first_val, second_val): + registry.register(name="incrementer", component=IncrementerComponent) + registry.register(name="parent_with_args", component=self.ParentComponentWithArgs) + registry.register(name="variable_display", component=VariableDisplay) template_str: types.django_html = """ {% load component_tags %} @@ -283,7 +308,7 @@ class ParentArgsTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context()) - self.assertHTMLEqual( + assertHTMLEqual( rendered, f"""
@@ -299,26 +324,25 @@ class ParentArgsTests(BaseTestCase): ) -class ContextCalledOnceTests(BaseTestCase): - def setUp(self): - super().setUp() +@djc_test +class TestContextCalledOnce: + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_one_context_call_with_simple_component(self, components_settings): registry.register(name="incrementer", component=IncrementerComponent) - - @parametrize_context_behavior(["django", "isolated"]) - def test_one_context_call_with_simple_component(self): template_str: types.django_html = """ {% load component_tags %} {% component name='incrementer' %}{% endcomponent %} """ template = Template(template_str) rendered = template.render(Context()).strip().replace("\n", "") - self.assertHTMLEqual( + assertHTMLEqual( rendered, '

value=1;calls=1

', ) - @parametrize_context_behavior(["django", "isolated"]) - def test_one_context_call_with_simple_component_and_arg(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_one_context_call_with_simple_component_and_arg(self, components_settings): + registry.register(name="incrementer", component=IncrementerComponent) template_str: types.django_html = """ {% load component_tags %} {% component name='incrementer' value='2' %}{% endcomponent %} @@ -326,15 +350,16 @@ class ContextCalledOnceTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context()).strip() - self.assertHTMLEqual( + assertHTMLEqual( rendered, """

value=3;calls=1

""", ) - @parametrize_context_behavior(["django", "isolated"]) - def test_one_context_call_with_component(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_one_context_call_with_component(self, components_settings): + registry.register(name="incrementer", component=IncrementerComponent) template_str: types.django_html = """ {% load component_tags %} {% component 'incrementer' %}{% endcomponent %} @@ -342,10 +367,11 @@ class ContextCalledOnceTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context()).strip() - self.assertHTMLEqual(rendered, '

value=1;calls=1

') + assertHTMLEqual(rendered, '

value=1;calls=1

') - @parametrize_context_behavior(["django", "isolated"]) - def test_one_context_call_with_component_and_arg(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_one_context_call_with_component_and_arg(self, components_settings): + registry.register(name="incrementer", component=IncrementerComponent) template_str: types.django_html = """ {% load component_tags %} {% component 'incrementer' value='3' %}{% endcomponent %} @@ -353,10 +379,11 @@ class ContextCalledOnceTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context()).strip() - self.assertHTMLEqual(rendered, '

value=4;calls=1

') + assertHTMLEqual(rendered, '

value=4;calls=1

') - @parametrize_context_behavior(["django", "isolated"]) - def test_one_context_call_with_slot(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_one_context_call_with_slot(self, components_settings): + registry.register(name="incrementer", component=IncrementerComponent) template_str: types.django_html = """ {% load component_tags %} {% component 'incrementer' %} @@ -368,7 +395,7 @@ class ContextCalledOnceTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context()).strip() - self.assertHTMLEqual( + assertHTMLEqual( rendered, """

value=1;calls=1

@@ -377,8 +404,9 @@ class ContextCalledOnceTests(BaseTestCase): rendered, ) - @parametrize_context_behavior(["django", "isolated"]) - def test_one_context_call_with_slot_and_arg(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_one_context_call_with_slot_and_arg(self, components_settings): + registry.register(name="incrementer", component=IncrementerComponent) template_str: types.django_html = """ {% load component_tags %} {% component 'incrementer' value='3' %} @@ -390,7 +418,7 @@ class ContextCalledOnceTests(BaseTestCase): template = Template(template_str) rendered = template.render(Context()).strip() - self.assertHTMLEqual( + assertHTMLEqual( rendered, """

value=4;calls=1

@@ -400,92 +428,92 @@ class ContextCalledOnceTests(BaseTestCase): ) -class ComponentsCanAccessOuterContext(BaseTestCase): - def setUp(self): - super().setUp() - registry.register(name="simple_component", component=SimpleComponent) - - # NOTE: Second arg in tuple is expected value. - @parametrize_context_behavior( - [ - ("django", "outer_value"), - ("isolated", ""), - ] +@djc_test +class TestComponentsCanAccessOuterContext: + @djc_test( + parametrize=( + ["components_settings", "expected_value"], + [ + [{"context_behavior": "django"}, "outer_value"], + [{"context_behavior": "isolated"}, ""], + ], + ["django", "isolated"], + ) ) - def test_simple_component_can_use_outer_context(self, context_behavior_data): + def test_simple_component_can_use_outer_context(self, components_settings, expected_value): + registry.register(name="simple_component", component=SimpleComponent) template_str: types.django_html = """ {% load component_tags %} {% component 'simple_component' %}{% endcomponent %} """ template = Template(template_str) rendered = template.render(Context({"variable": "outer_value"})) - self.assertHTMLEqual( + assertHTMLEqual( rendered, f""" - Variable: {context_behavior_data} + Variable: {expected_value} """, ) -class IsolatedContextTests(BaseTestCase): - def setUp(self): - super().setUp() +@djc_test +class TestIsolatedContext: + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_simple_component_can_pass_outer_context_in_args(self, components_settings): registry.register(name="simple_component", component=SimpleComponent) - - @parametrize_context_behavior(["django", "isolated"]) - def test_simple_component_can_pass_outer_context_in_args(self): template_str: types.django_html = """ {% load component_tags %} {% component 'simple_component' variable only %}{% endcomponent %} """ template = Template(template_str) rendered = template.render(Context({"variable": "outer_value"})).strip() - self.assertIn("outer_value", rendered) + assert "outer_value" in rendered - @parametrize_context_behavior(["django", "isolated"]) - def test_simple_component_cannot_use_outer_context(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_simple_component_cannot_use_outer_context(self, components_settings): + registry.register(name="simple_component", component=SimpleComponent) template_str: types.django_html = """ {% load component_tags %} {% component 'simple_component' only %}{% endcomponent %} """ template = Template(template_str) rendered = template.render(Context({"variable": "outer_value"})).strip() - self.assertNotIn("outer_value", rendered) + assert "outer_value" not in rendered -class IsolatedContextSettingTests(BaseTestCase): - def setUp(self): - super().setUp() - registry.register(name="simple_component", component=SimpleComponent) - - @parametrize_context_behavior(["isolated"]) +@djc_test +class TestIsolatedContextSetting: + @djc_test(components_settings={"context_behavior": "isolated"}) def test_component_tag_includes_variable_with_isolated_context_from_settings( self, ): + registry.register(name="simple_component", component=SimpleComponent) template_str: types.django_html = """ {% load component_tags %} {% component 'simple_component' variable %}{% endcomponent %} """ template = Template(template_str) rendered = template.render(Context({"variable": "outer_value"})) - self.assertIn("outer_value", rendered) + assert "outer_value" in rendered - @parametrize_context_behavior(["isolated"]) + @djc_test(components_settings={"context_behavior": "isolated"}) def test_component_tag_excludes_variable_with_isolated_context_from_settings( self, ): + registry.register(name="simple_component", component=SimpleComponent) template_str: types.django_html = """ {% load component_tags %} {% component 'simple_component' %}{% endcomponent %} """ template = Template(template_str) rendered = template.render(Context({"variable": "outer_value"})) - self.assertNotIn("outer_value", rendered) + assert "outer_value" not in rendered - @parametrize_context_behavior(["isolated"]) + @djc_test(components_settings={"context_behavior": "isolated"}) def test_component_includes_variable_with_isolated_context_from_settings( self, ): + registry.register(name="simple_component", component=SimpleComponent) template_str: types.django_html = """ {% load component_tags %} {% component 'simple_component' variable %} @@ -493,12 +521,13 @@ class IsolatedContextSettingTests(BaseTestCase): """ template = Template(template_str) rendered = template.render(Context({"variable": "outer_value"})) - self.assertIn("outer_value", rendered) + assert "outer_value" in rendered - @parametrize_context_behavior(["isolated"]) + @djc_test(components_settings={"context_behavior": "isolated"}) def test_component_excludes_variable_with_isolated_context_from_settings( self, ): + registry.register(name="simple_component", component=SimpleComponent) template_str: types.django_html = """ {% load component_tags %} {% component 'simple_component' %} @@ -506,12 +535,13 @@ class IsolatedContextSettingTests(BaseTestCase): """ template = Template(template_str) rendered = template.render(Context({"variable": "outer_value"})) - self.assertNotIn("outer_value", rendered) + assert "outer_value" not in rendered -class ContextProcessorsTests(BaseTestCase): - @parametrize_context_behavior(["django", "isolated"]) - def test_request_context_in_template(self): +@djc_test +class TestContextProcessors: + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_request_context_in_template(self, components_settings): context_processors_data: Optional[Dict] = None inner_request: Optional[HttpRequest] = None @@ -537,12 +567,12 @@ class ContextProcessorsTests(BaseTestCase): template = Template(template_str) rendered = template.render(request_context) - self.assertIn("csrfmiddlewaretoken", rendered) - self.assertEqual(list(context_processors_data.keys()), ["csrf_token"]) # type: ignore[union-attr] - self.assertEqual(inner_request, request) + assert "csrfmiddlewaretoken" in rendered + assert list(context_processors_data.keys()) == ["csrf_token"] # type: ignore[union-attr] + assert inner_request == request - @parametrize_context_behavior(["django", "isolated"]) - def test_request_context_in_template_nested(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_request_context_in_template_nested(self, components_settings): context_processors_data = None context_processors_data_child = None parent_request: Optional[HttpRequest] = None @@ -583,14 +613,14 @@ class ContextProcessorsTests(BaseTestCase): template = Template(template_str) rendered = template.render(request_context) - self.assertIn("csrfmiddlewaretoken", rendered) - self.assertEqual(list(context_processors_data.keys()), ["csrf_token"]) # type: ignore[union-attr] - self.assertEqual(list(context_processors_data_child.keys()), ["csrf_token"]) # type: ignore[union-attr] - self.assertEqual(parent_request, request) - self.assertEqual(child_request, request) + assert "csrfmiddlewaretoken" in rendered + assert list(context_processors_data.keys()) == ["csrf_token"] # type: ignore[union-attr] + assert list(context_processors_data_child.keys()) == ["csrf_token"] # type: ignore[union-attr] + assert parent_request == request + assert child_request == request - @parametrize_context_behavior(["django", "isolated"]) - def test_request_context_in_template_slot(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_request_context_in_template_slot(self, components_settings): context_processors_data = None context_processors_data_child = None parent_request: Optional[HttpRequest] = None @@ -635,14 +665,14 @@ class ContextProcessorsTests(BaseTestCase): template = Template(template_str) rendered = template.render(request_context) - self.assertIn("csrfmiddlewaretoken", rendered) - self.assertEqual(list(context_processors_data.keys()), ["csrf_token"]) # type: ignore[union-attr] - self.assertEqual(list(context_processors_data_child.keys()), ["csrf_token"]) # type: ignore[union-attr] - self.assertEqual(parent_request, request) - self.assertEqual(child_request, request) + assert "csrfmiddlewaretoken" in rendered + assert list(context_processors_data.keys()) == ["csrf_token"] # type: ignore[union-attr] + assert list(context_processors_data_child.keys()) == ["csrf_token"] # type: ignore[union-attr] + assert parent_request == request + assert child_request == request - @parametrize_context_behavior(["django", "isolated"]) - def test_request_context_in_python(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_request_context_in_python(self, components_settings): context_processors_data = None inner_request: Optional[HttpRequest] = None @@ -661,12 +691,12 @@ class ContextProcessorsTests(BaseTestCase): request_context = RequestContext(request) rendered = TestComponent.render(request_context) - self.assertIn("csrfmiddlewaretoken", rendered) - self.assertEqual(list(context_processors_data.keys()), ["csrf_token"]) # type: ignore[union-attr] - self.assertEqual(inner_request, request) + assert "csrfmiddlewaretoken" in rendered + assert list(context_processors_data.keys()) == ["csrf_token"] # type: ignore[union-attr] + assert inner_request == request - @parametrize_context_behavior(["django", "isolated"]) - def test_request_context_in_python_nested(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_request_context_in_python_nested(self, components_settings): context_processors_data: Optional[Dict] = None context_processors_data_child: Optional[Dict] = None parent_request: Optional[HttpRequest] = None @@ -701,14 +731,14 @@ class ContextProcessorsTests(BaseTestCase): request_context = RequestContext(request) rendered = TestParentComponent.render(request_context) - self.assertIn("csrfmiddlewaretoken", rendered) - self.assertEqual(list(context_processors_data.keys()), ["csrf_token"]) # type: ignore[union-attr] - self.assertEqual(list(context_processors_data_child.keys()), ["csrf_token"]) # type: ignore[union-attr] - self.assertEqual(parent_request, request) - self.assertEqual(child_request, request) + assert "csrfmiddlewaretoken" in rendered + assert list(context_processors_data.keys()) == ["csrf_token"] # type: ignore[union-attr] + assert list(context_processors_data_child.keys()) == ["csrf_token"] # type: ignore[union-attr] + assert parent_request == request + assert child_request == request - @parametrize_context_behavior(["django", "isolated"]) - def test_request_in_python(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_request_in_python(self, components_settings): context_processors_data: Optional[Dict] = None inner_request: Optional[HttpRequest] = None @@ -726,12 +756,12 @@ class ContextProcessorsTests(BaseTestCase): request = HttpRequest() rendered = TestComponent.render(request=request) - self.assertIn("csrfmiddlewaretoken", rendered) - self.assertEqual(list(context_processors_data.keys()), ["csrf_token"]) # type: ignore[union-attr] - self.assertEqual(inner_request, request) + assert "csrfmiddlewaretoken" in rendered + assert list(context_processors_data.keys()) == ["csrf_token"] # type: ignore[union-attr] + assert inner_request == request - @parametrize_context_behavior(["django", "isolated"]) - def test_request_in_python_nested(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_request_in_python_nested(self, components_settings): context_processors_data: Optional[Dict] = None context_processors_data_child: Optional[Dict] = None parent_request: Optional[HttpRequest] = None @@ -765,15 +795,15 @@ class ContextProcessorsTests(BaseTestCase): request = HttpRequest() rendered = TestParentComponent.render(request=request) - self.assertIn("csrfmiddlewaretoken", rendered) - self.assertEqual(list(context_processors_data.keys()), ["csrf_token"]) # type: ignore[union-attr] - self.assertEqual(list(context_processors_data_child.keys()), ["csrf_token"]) # type: ignore[union-attr] - self.assertEqual(parent_request, request) - self.assertEqual(child_request, request) + assert "csrfmiddlewaretoken" in rendered + assert list(context_processors_data.keys()) == ["csrf_token"] # type: ignore[union-attr] + assert list(context_processors_data_child.keys()) == ["csrf_token"] # type: ignore[union-attr] + assert parent_request == request + assert child_request == request # No request, regular Context - @parametrize_context_behavior(["django", "isolated"]) - def test_no_context_processor_when_non_request_context_in_python(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_no_context_processor_when_non_request_context_in_python(self, components_settings): context_processors_data: Optional[Dict] = None inner_request: Optional[HttpRequest] = None @@ -790,13 +820,13 @@ class ContextProcessorsTests(BaseTestCase): rendered = TestComponent.render(context=Context()) - self.assertNotIn("csrfmiddlewaretoken", rendered) - self.assertEqual(list(context_processors_data.keys()), []) # type: ignore[union-attr] - self.assertEqual(inner_request, None) + assert "csrfmiddlewaretoken" not in rendered + assert list(context_processors_data.keys()) == [] # type: ignore[union-attr] + assert inner_request is None # No request, no Context - @parametrize_context_behavior(["django", "isolated"]) - def test_no_context_processor_when_non_request_context_in_python_2(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_no_context_processor_when_non_request_context_in_python_2(self, components_settings): context_processors_data: Optional[Dict] = None inner_request: Optional[HttpRequest] = None @@ -813,13 +843,13 @@ class ContextProcessorsTests(BaseTestCase): rendered = TestComponent.render() - self.assertNotIn("csrfmiddlewaretoken", rendered) - self.assertEqual(list(context_processors_data.keys()), []) # type: ignore[union-attr] - self.assertEqual(inner_request, None) + assert "csrfmiddlewaretoken" not in rendered + assert list(context_processors_data.keys()) == [] # type: ignore[union-attr] + assert inner_request is None # Yes request, regular Context - @parametrize_context_behavior(["django", "isolated"]) - def test_context_processor_when_regular_context_and_request_in_python(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_context_processor_when_regular_context_and_request_in_python(self, components_settings): context_processors_data: Optional[Dict] = None inner_request: Optional[HttpRequest] = None @@ -837,17 +867,17 @@ class ContextProcessorsTests(BaseTestCase): request = HttpRequest() rendered = TestComponent.render(Context(), request=request) - self.assertIn("csrfmiddlewaretoken", rendered) - self.assertEqual(list(context_processors_data.keys()), ["csrf_token"]) # type: ignore[union-attr] - self.assertEqual(inner_request, request) + assert "csrfmiddlewaretoken" in rendered + assert list(context_processors_data.keys()) == ["csrf_token"] # type: ignore[union-attr] + assert inner_request == request def test_raises_on_accessing_context_processors_data_outside_of_rendering(self): class TestComponent(Component): template: types.django_html = """{% csrf_token %}""" - with self.assertRaisesMessage( + with pytest.raises( RuntimeError, - "Tried to access Component's `context_processors_data` attribute while outside of rendering execution", + match=re.escape("Tried to access Component's `context_processors_data` attribute while outside of rendering execution"), # noqa: E501 ): TestComponent().context_processors_data @@ -855,38 +885,37 @@ class ContextProcessorsTests(BaseTestCase): class TestComponent(Component): template: types.django_html = """{% csrf_token %}""" - with self.assertRaisesMessage( + with pytest.raises( RuntimeError, - "Tried to access Component's `request` attribute while outside of rendering execution", + match=re.escape("Tried to access Component's `request` attribute while outside of rendering execution"), ): TestComponent().request -class OuterContextPropertyTests(BaseTestCase): - class OuterContextComponent(Component): - template: types.django_html = """ - Variable: {{ variable }} - """ +@djc_test +class TestOuterContextProperty: + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_outer_context_property_with_component(self, components_settings): + @register("outer_context_component") + class OuterContextComponent(Component): + template: types.django_html = """ + Variable: {{ variable }} + """ - def get_context_data(self): - return self.outer_context.flatten() # type: ignore[union-attr] + def get_context_data(self): + return self.outer_context.flatten() # type: ignore[union-attr] - def setUp(self): - super().setUp() - registry.register(name="outer_context_component", component=self.OuterContextComponent) - - @parametrize_context_behavior(["django", "isolated"]) - def test_outer_context_property_with_component(self): template_str: types.django_html = """ {% load component_tags %} {% component 'outer_context_component' only %}{% endcomponent %} """ template = Template(template_str) rendered = template.render(Context({"variable": "outer_value"})).strip() - self.assertIn("outer_value", rendered) + assert "outer_value" in rendered -class ContextVarsIsFilledTests(BaseTestCase): +@djc_test +class TestContextVarsIsFilled: class IsFilledVarsComponent(Component): template: types.django_html = """ {% load component_tags %} @@ -936,16 +965,8 @@ class ContextVarsIsFilledTests(BaseTestCase):
""" - def setUp(self) -> None: - super().setUp() - registry.register("conditional_slots", self.ComponentWithConditionalSlots) - registry.register( - "complex_conditional_slots", - self.ComponentWithComplexConditionalSlots, - ) - - @parametrize_context_behavior(["django", "isolated"]) - def test_is_filled_vars(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_is_filled_vars(self, components_settings): registry.register("is_filled_vars", self.IsFilledVarsComponent) template: types.django_html = """ @@ -968,10 +989,10 @@ class ContextVarsIsFilledTests(BaseTestCase): escape_this_________: True
""" - self.assertHTMLEqual(rendered, expected) + assertHTMLEqual(rendered, expected) - @parametrize_context_behavior(["django", "isolated"]) - def test_is_filled_vars_default(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_is_filled_vars_default(self, components_settings): registry.register("is_filled_vars", self.IsFilledVarsComponent) template: types.django_html = """ @@ -991,10 +1012,12 @@ class ContextVarsIsFilledTests(BaseTestCase): escape_this_________: False """ - self.assertHTMLEqual(rendered, expected) + assertHTMLEqual(rendered, expected) + + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_simple_component_with_conditional_slot(self, components_settings): + registry.register("conditional_slots", self.ComponentWithConditionalSlots) - @parametrize_context_behavior(["django", "isolated"]) - def test_simple_component_with_conditional_slot(self): template: types.django_html = """ {% load component_tags %} {% component "conditional_slots" %}{% endcomponent %} @@ -1007,10 +1030,12 @@ class ContextVarsIsFilledTests(BaseTestCase): """ rendered = Template(template).render(Context({})) - self.assertHTMLEqual(rendered, expected) + assertHTMLEqual(rendered, expected) + + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_component_with_filled_conditional_slot(self, components_settings): + registry.register("conditional_slots", self.ComponentWithConditionalSlots) - @parametrize_context_behavior(["django", "isolated"]) - def test_component_with_filled_conditional_slot(self): template: types.django_html = """ {% load component_tags %} {% component "conditional_slots" %} @@ -1028,10 +1053,15 @@ class ContextVarsIsFilledTests(BaseTestCase): """ rendered = Template(template).render(Context({})) - self.assertHTMLEqual(rendered, expected) + assertHTMLEqual(rendered, expected) + + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_elif_of_complex_conditional_slots(self, components_settings): + registry.register( + "complex_conditional_slots", + self.ComponentWithComplexConditionalSlots, + ) - @parametrize_context_behavior(["django", "isolated"]) - def test_elif_of_complex_conditional_slots(self): template: types.django_html = """ {% load component_tags %} {% component "complex_conditional_slots" %} @@ -1049,10 +1079,15 @@ class ContextVarsIsFilledTests(BaseTestCase): """ rendered = Template(template).render(Context({})) - self.assertHTMLEqual(rendered, expected) + assertHTMLEqual(rendered, expected) + + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_else_of_complex_conditional_slots(self, components_settings): + registry.register( + "complex_conditional_slots", + self.ComponentWithComplexConditionalSlots, + ) - @parametrize_context_behavior(["django", "isolated"]) - def test_else_of_complex_conditional_slots(self): template: types.django_html = """ {% load component_tags %} {% component "complex_conditional_slots" %} @@ -1067,10 +1102,10 @@ class ContextVarsIsFilledTests(BaseTestCase): """ rendered = Template(template).render(Context({})) - self.assertHTMLEqual(rendered, expected) + assertHTMLEqual(rendered, expected) - @parametrize_context_behavior(["django", "isolated"]) - def test_component_with_negated_conditional_slot(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_component_with_negated_conditional_slot(self, components_settings): @register("negated_conditional_slot") class ComponentWithNegatedConditionalSlot(Component): template: types.django_html = """ @@ -1101,10 +1136,10 @@ class ContextVarsIsFilledTests(BaseTestCase): """ rendered = Template(template).render(Context({})) - self.assertHTMLEqual(rendered, expected) + assertHTMLEqual(rendered, expected) - @parametrize_context_behavior(["django", "isolated"]) - def test_is_filled_vars_in_hooks(self): + @djc_test(parametrize=PARAMETRIZE_CONTEXT_BEHAVIOR) + def test_is_filled_vars_in_hooks(self, components_settings): captured_before = None captured_after = None @@ -1127,5 +1162,5 @@ class ContextVarsIsFilledTests(BaseTestCase): Template(template).render(Context()) expected = {"default": True} - self.assertEqual(captured_before, expected) - self.assertEqual(captured_after, expected) + assert captured_before == expected + assert captured_after == expected diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 3ce7a049..048b28f2 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -8,15 +8,17 @@ For checking the OUTPUT of the dependencies, see `test_dependency_rendering.py`. import re from unittest.mock import Mock +import pytest from django.http import HttpResponseNotModified from django.template import Context, Template +from pytest_django.asserts import assertHTMLEqual, assertInHTML from django_components import Component, registry, render_dependencies, types from django_components.components.dynamic import DynamicComponent from django_components.middleware import ComponentDependencyMiddleware -from .django_test_setup import setup_test_config -from .testutils import BaseTestCase, create_and_process_template_response +from django_components.testing import djc_test +from .testutils import create_and_process_template_response, setup_test_config setup_test_config({"autodiscover": False}) @@ -47,7 +49,8 @@ class SimpleComponent(Component): js = "script.js" -class RenderDependenciesTests(BaseTestCase): +@djc_test +class TestRenderDependencies: def test_standalone_render_dependencies(self): registry.register(name="test", component=SimpleComponent) @@ -61,23 +64,23 @@ class RenderDependenciesTests(BaseTestCase): rendered_raw = template.render(Context({})) # Placeholders - self.assertEqual(rendered_raw.count(''), 1) - self.assertEqual(rendered_raw.count(''), 1) + assert rendered_raw.count('') == 1 + assert rendered_raw.count('') == 1 - self.assertEqual(rendered_raw.count("', rendered, count=1) + assertInHTML('', rendered, count=1) - self.assertInHTML("", rendered, count=1) # Inlined CSS - self.assertInHTML('', rendered, count=1) # Inlined JS + assertInHTML("", rendered, count=1) # Inlined CSS + assertInHTML('', rendered, count=1) # Inlined JS - self.assertInHTML('', rendered, count=1) # Media.css + assertInHTML('', rendered, count=1) # Media.css def test_middleware_renders_dependencies(self): registry.register(name="test", component=SimpleComponent) @@ -92,14 +95,14 @@ class RenderDependenciesTests(BaseTestCase): rendered = create_and_process_template_response(template, use_middleware=True) # Dependency manager script - self.assertInHTML('', rendered, count=1) + assertInHTML('', rendered, count=1) - self.assertInHTML("", rendered, count=1) # Inlined CSS - self.assertInHTML('', rendered, count=1) # Inlined JS + assertInHTML("", rendered, count=1) # Inlined CSS + assertInHTML('', rendered, count=1) # Inlined JS - self.assertInHTML('', rendered, count=1) # Media.css - self.assertEqual(rendered.count("', rendered, count=1) # Media.css + assert rendered.count("', rendered, count=1) + assertInHTML('', rendered, count=1) - self.assertInHTML("", rendered, count=1) # Inlined CSS - self.assertInHTML('', rendered, count=1) # Inlined JS + assertInHTML("", rendered, count=1) # Inlined CSS + assertInHTML('', rendered, count=1) # Inlined JS - self.assertInHTML('', rendered, count=1) # Media.css - self.assertEqual(rendered.count("', rendered, count=1) # Media.css + assert rendered.count("', rendered_raw, count=0) + assertInHTML('', rendered_raw, count=0) - self.assertInHTML("", rendered_raw, count=0) # Inlined CSS - self.assertInHTML('', rendered_raw, count=0) # Media.css + assertInHTML("", rendered_raw, count=0) # Inlined CSS + assertInHTML('', rendered_raw, count=0) # Media.css - self.assertInHTML( + assertInHTML( '', rendered_raw, count=0, @@ -182,14 +185,14 @@ class RenderDependenciesTests(BaseTestCase): rendered = response.content.decode() # Dependency manager script - self.assertInHTML('', rendered, count=1) + assertInHTML('', rendered, count=1) - self.assertInHTML("", rendered, count=1) # Inlined CSS - self.assertInHTML('', rendered, count=1) # Inlined JS + assertInHTML("", rendered, count=1) # Inlined CSS + assertInHTML('', rendered, count=1) # Inlined JS - self.assertEqual(rendered.count(''), 1) # Media.css - self.assertEqual(rendered.count("') == 1 # Media.css + assert rendered.count(" @@ -226,12 +229,12 @@ class RenderDependenciesTests(BaseTestCase): body_re = re.compile(r"(.*?)", re.DOTALL) rendered_body = body_re.search(rendered).group(1) # type: ignore[union-attr] - self.assertInHTML( + assertInHTML( """', rendered_body, count=1, @@ -256,12 +259,12 @@ class RenderDependenciesTests(BaseTestCase): rendered_raw = Template(template_str).render(Context({})) rendered = render_dependencies(rendered_raw) - self.assertEqual(rendered.count(" Variable: foo @@ -277,12 +280,12 @@ class RenderDependenciesTests(BaseTestCase): head_re = re.compile(r"(.*?)", re.DOTALL) rendered_head = head_re.search(rendered).group(1) # type: ignore[union-attr] - self.assertInHTML( + assertInHTML( """', rendered_head, count=1, @@ -300,7 +303,7 @@ class RenderDependenciesTests(BaseTestCase): rendered_raw = Template(template_str).render(Context({"formset": [1]})) rendered = render_dependencies(rendered_raw, type="fragment") - self.assertHTMLEqual(rendered, "") + assertHTMLEqual(rendered, "") def test_does_not_modify_html_when_no_component_used(self): registry.register(name="test", component=SimpleComponent) @@ -358,7 +361,7 @@ class RenderDependenciesTests(BaseTestCase): """ - self.assertHTMLEqual(expected, rendered) + assertHTMLEqual(expected, rendered) # Explanation: The component is used in the template, but the template doesn't use # {% component_js_dependencies %} or {% component_css_dependencies %} tags, @@ -435,7 +438,7 @@ class RenderDependenciesTests(BaseTestCase): """ # noqa: E501 - self.assertHTMLEqual(expected, rendered) + assertHTMLEqual(expected, rendered) def test_raises_if_script_end_tag_inside_component_js(self): class ComponentWithScript(SimpleComponent): @@ -445,9 +448,9 @@ class RenderDependenciesTests(BaseTestCase): registry.register(name="test", component=ComponentWithScript) - with self.assertRaisesMessage( + with pytest.raises( RuntimeError, - "Content of `Component.js` for component 'ComponentWithScript' contains '' end tag.", + match=re.escape("Content of `Component.js` for component 'ComponentWithScript' contains '' end tag."), # noqa: E501 ): ComponentWithScript.render(kwargs={"variable": "foo"}) @@ -462,19 +465,20 @@ class RenderDependenciesTests(BaseTestCase): registry.register(name="test", component=ComponentWithScript) - with self.assertRaisesMessage( + with pytest.raises( RuntimeError, - "Content of `Component.css` for component 'ComponentWithScript' contains '' end tag.", + match=re.escape("Content of `Component.css` for component 'ComponentWithScript' contains '' end tag."), # noqa: E501 ): ComponentWithScript.render(kwargs={"variable": "foo"}) -class MiddlewareTests(BaseTestCase): +@djc_test +class TestMiddleware: def test_middleware_response_without_content_type(self): response = HttpResponseNotModified() middleware = ComponentDependencyMiddleware(get_response=lambda _: response) request = Mock() - self.assertEqual(response, middleware(request=request)) + assert response == middleware(request=request) def test_middleware_response_with_components_with_slash_dash_and_underscore(self): registry.register("dynamic", DynamicComponent) @@ -492,14 +496,14 @@ class MiddlewareTests(BaseTestCase): def assert_dependencies(content: str): # Dependency manager script (empty) - self.assertInHTML('', content, count=1) + assertInHTML('', content, count=1) # Inlined JS - self.assertInHTML('', content, count=1) + assertInHTML('', content, count=1) # Inlined CSS - self.assertInHTML("", content, count=1) + assertInHTML("", content, count=1) # Media.css - self.assertInHTML('', content, count=1) + assertInHTML('', content, count=1) rendered1 = create_and_process_template_response( template, @@ -507,10 +511,7 @@ class MiddlewareTests(BaseTestCase): ) assert_dependencies(rendered1) - self.assertEqual( - rendered1.count('Variable: value'), - 1, - ) + assert rendered1.count('Variable: value') == 1 rendered2 = create_and_process_template_response( template, @@ -518,10 +519,7 @@ class MiddlewareTests(BaseTestCase): ) assert_dependencies(rendered2) - self.assertEqual( - rendered2.count('Variable: value'), - 1, - ) + assert rendered2.count('Variable: value') == 1 rendered3 = create_and_process_template_response( template, @@ -529,7 +527,4 @@ class MiddlewareTests(BaseTestCase): ) assert_dependencies(rendered3) - self.assertEqual( - rendered3.count('Variable: value'), - 1, - ) + assert rendered3.count('Variable: value') == 1 diff --git a/tests/test_dependency_manager.py b/tests/test_dependency_manager.py index 6eb4d86d..57c307c5 100644 --- a/tests/test_dependency_manager.py +++ b/tests/test_dependency_manager.py @@ -1,12 +1,13 @@ +import re from typing import List -from django.test import override_settings -from playwright.async_api import Error, Page +import pytest +from playwright.async_api import Browser, Error, Page from django_components import types -from tests.django_test_setup import setup_test_config +from django_components.testing import djc_test +from tests.testutils import setup_test_config from tests.e2e.utils import TEST_SERVER_URL, with_playwright -from tests.testutils import BaseTestCase setup_test_config( components={"autodiscover": False}, @@ -18,69 +19,73 @@ setup_test_config( urlpatterns: List = [] -class _BaseDepManagerTestCase(BaseTestCase): - async def _create_page_with_dep_manager(self) -> Page: - page = await self.browser.new_page() +async def _create_page_with_dep_manager(browser: Browser) -> Page: + page = await browser.new_page() - # Load the JS library by opening a page with the script, and then running the script code - # E.g. `http://localhost:54017/static/django_components/django_components.min.js` - script_url = TEST_SERVER_URL + "/static/django_components/django_components.min.js" - await page.goto(script_url) + # Load the JS library by opening a page with the script, and then running the script code + # E.g. `http://localhost:54017/static/django_components/django_components.min.js` + script_url = TEST_SERVER_URL + "/static/django_components/django_components.min.js" + await page.goto(script_url) - # The page's body is the script code. We load it by executing the code - await page.evaluate( - """ - () => { - eval(document.body.textContent); - } - """ - ) + # The page's body is the script code. We load it by executing the code + await page.evaluate( + """ + () => { + eval(document.body.textContent); + } + """ + ) - # Ensure the body is clear - await page.evaluate( - """ - () => { - document.body.innerHTML = ''; - document.head.innerHTML = ''; - } - """ - ) + # Ensure the body is clear + await page.evaluate( + """ + () => { + document.body.innerHTML = ''; + document.head.innerHTML = ''; + } + """ + ) - return page + return page -@override_settings(STATIC_URL="static/") -class DependencyManagerTests(_BaseDepManagerTestCase): +@djc_test( + django_settings={ + "STATIC_URL": "static/", + } +) +class TestDependencyManager: @with_playwright async def test_script_loads(self): - page = await self._create_page_with_dep_manager() + page = await _create_page_with_dep_manager(self.browser) # type: ignore[attr-defined] # Check the exposed API keys = sorted(await page.evaluate("Object.keys(Components)")) - self.assertEqual(keys, ["createComponentsManager", "manager", "unescapeJs"]) + assert keys == ["createComponentsManager", "manager", "unescapeJs"] keys = await page.evaluate("Object.keys(Components.manager)") - self.assertEqual( - keys, - [ - "callComponent", - "registerComponent", - "registerComponentData", - "loadJs", - "loadCss", - "markScriptLoaded", - ], - ) + assert keys == [ + "callComponent", + "registerComponent", + "registerComponentData", + "loadJs", + "loadCss", + "markScriptLoaded", + ] await page.close() # Tests for `manager.loadJs()` / `manager.loadCss()` / `manager.markAsLoaded()` -@override_settings(STATIC_URL="static/") -class LoadScriptTests(_BaseDepManagerTestCase): +@djc_test( + django_settings={ + "STATIC_URL": "static/", + } +) +class TestLoadScript: @with_playwright async def test_load_js_scripts(self): - page = await self._create_page_with_dep_manager() + page = await _create_page_with_dep_manager(self.browser) # type: ignore[attr-defined] # JS code that loads a few dependencies, capturing the HTML after each action test_js: types.js = """() => { @@ -113,20 +118,18 @@ class LoadScriptTests(_BaseDepManagerTestCase): data = await page.evaluate(test_js) - self.assertEqual(data["bodyAfterFirstLoad"], '') - self.assertEqual(data["bodyAfterSecondLoad"], '') - self.assertEqual( - data["bodyAfterThirdLoad"], '' - ) + assert data["bodyAfterFirstLoad"] == '' + assert data["bodyAfterSecondLoad"] == '' + assert data["bodyAfterThirdLoad"] == '' - self.assertEqual(data["headBeforeFirstLoad"], data["headAfterThirdLoad"]) - self.assertEqual(data["headBeforeFirstLoad"], "") + assert data["headBeforeFirstLoad"] == data["headAfterThirdLoad"] + assert data["headBeforeFirstLoad"] == "" await page.close() @with_playwright async def test_load_css_scripts(self): - page = await self._create_page_with_dep_manager() + page = await _create_page_with_dep_manager(self.browser) # type: ignore[attr-defined] # JS code that loads a few dependencies, capturing the HTML after each action test_js: types.js = """() => { @@ -159,18 +162,18 @@ class LoadScriptTests(_BaseDepManagerTestCase): data = await page.evaluate(test_js) - self.assertEqual(data["headAfterFirstLoad"], '') - self.assertEqual(data["headAfterSecondLoad"], '') - self.assertEqual(data["headAfterThirdLoad"], '') + assert data["headAfterFirstLoad"] == '' + assert data["headAfterSecondLoad"] == '' + assert data["headAfterThirdLoad"] == '' - self.assertEqual(data["bodyBeforeFirstLoad"], data["bodyAfterThirdLoad"]) - self.assertEqual(data["bodyBeforeFirstLoad"], "") + assert data["bodyBeforeFirstLoad"] == data["bodyAfterThirdLoad"] + assert data["bodyBeforeFirstLoad"] == "" await page.close() @with_playwright async def test_does_not_load_script_if_marked_as_loaded(self): - page = await self._create_page_with_dep_manager() + page = await _create_page_with_dep_manager(self.browser) # type: ignore[attr-defined] # JS code that loads a few dependencies, capturing the HTML after each action test_js: types.js = """() => { @@ -194,18 +197,22 @@ class LoadScriptTests(_BaseDepManagerTestCase): data = await page.evaluate(test_js) - self.assertEqual(data["headAfterFirstLoad"], "") - self.assertEqual(data["bodyAfterSecondLoad"], "") + assert data["headAfterFirstLoad"] == "" + assert data["bodyAfterSecondLoad"] == "" await page.close() # Tests for `manager.registerComponent()` / `registerComponentData()` / `callComponent()` -@override_settings(STATIC_URL="static/") -class CallComponentTests(_BaseDepManagerTestCase): +@djc_test( + django_settings={ + "STATIC_URL": "static/", + } +) +class TestCallComponent: @with_playwright async def test_calls_component_successfully(self): - page = await self._create_page_with_dep_manager() + page = await _create_page_with_dep_manager(self.browser) # type: ignore[attr-defined] test_js: types.js = """() => { const manager = Components.createComponentsManager(); @@ -240,26 +247,23 @@ class CallComponentTests(_BaseDepManagerTestCase): data = await page.evaluate(test_js) - self.assertEqual(data["result"], 123) - self.assertEqual( - data["captured"], - { - "data": { - "hello": "world", - }, - "ctx": { - "els": ['
abc
'], - "id": "12345", - "name": "my_comp", - }, + assert data["result"] == 123 + assert data["captured"] == { + "data": { + "hello": "world", }, - ) + "ctx": { + "els": ['
abc
'], + "id": "12345", + "name": "my_comp", + }, + } await page.close() @with_playwright async def test_calls_component_successfully_async(self): - page = await self._create_page_with_dep_manager() + page = await _create_page_with_dep_manager(self.browser) # type: ignore[attr-defined] test_js: types.js = """() => { const manager = Components.createComponentsManager(); @@ -292,14 +296,14 @@ class CallComponentTests(_BaseDepManagerTestCase): data = await page.evaluate(test_js) - self.assertEqual(data["result"], 123) - self.assertEqual(data["isPromise"], True) + assert data["result"] == 123 + assert data["isPromise"] is True await page.close() @with_playwright async def test_error_in_component_call_do_not_propagate_sync(self): - page = await self._create_page_with_dep_manager() + page = await _create_page_with_dep_manager(self.browser) # type: ignore[attr-defined] test_js: types.js = """() => { const manager = Components.createComponentsManager(); @@ -327,13 +331,13 @@ class CallComponentTests(_BaseDepManagerTestCase): data = await page.evaluate(test_js) - self.assertEqual(data, None) + assert data is None await page.close() @with_playwright async def test_error_in_component_call_do_not_propagate_async(self): - page = await self._create_page_with_dep_manager() + page = await _create_page_with_dep_manager(self.browser) # type: ignore[attr-defined] test_js: types.js = """() => { const manager = Components.createComponentsManager(); @@ -360,16 +364,16 @@ class CallComponentTests(_BaseDepManagerTestCase): data = await page.evaluate(test_js) - self.assertEqual(len(data), 1) - self.assertEqual(data[0]["status"], "rejected") - self.assertIsInstance(data[0]["reason"], Error) - self.assertEqual(data[0]["reason"].message, "Oops!") + assert len(data) == 1 + assert data[0]["status"] == "rejected" + assert isinstance(data[0]["reason"], Error) + assert data[0]["reason"].message == "Oops!" await page.close() @with_playwright async def test_raises_if_component_element_not_in_dom(self): - page = await self._create_page_with_dep_manager() + page = await _create_page_with_dep_manager(self.browser) # type: ignore[attr-defined] test_js: types.js = """() => { const manager = Components.createComponentsManager(); @@ -390,8 +394,9 @@ class CallComponentTests(_BaseDepManagerTestCase): manager.callComponent(compName, compId, inputHash); }""" - with self.assertRaisesMessage( - Error, "Error: [Components] 'my_comp': No elements with component ID '12345' found" + with pytest.raises( + Error, + match=re.escape("Error: [Components] 'my_comp': No elements with component ID '12345' found"), ): await page.evaluate(test_js) @@ -399,7 +404,7 @@ class CallComponentTests(_BaseDepManagerTestCase): @with_playwright async def test_raises_if_input_hash_not_registered(self): - page = await self._create_page_with_dep_manager() + page = await _create_page_with_dep_manager(self.browser) # type: ignore[attr-defined] test_js: types.js = """() => { const manager = Components.createComponentsManager(); @@ -418,14 +423,17 @@ class CallComponentTests(_BaseDepManagerTestCase): manager.callComponent(compName, compId, inputHash); }""" - with self.assertRaisesMessage(Error, "Error: [Components] 'my_comp': Cannot find input for hash 'input-abc'"): + with pytest.raises( + Error, + match=re.escape("Error: [Components] 'my_comp': Cannot find input for hash 'input-abc'"), + ): await page.evaluate(test_js) await page.close() @with_playwright async def test_raises_if_component_not_registered(self): - page = await self._create_page_with_dep_manager() + page = await _create_page_with_dep_manager(self.browser) # type: ignore[attr-defined] test_js: types.js = """() => { const manager = Components.createComponentsManager(); @@ -444,7 +452,10 @@ class CallComponentTests(_BaseDepManagerTestCase): manager.callComponent(compName, compId, inputHash); }""" - with self.assertRaisesMessage(Error, "Error: [Components] 'my_comp': No component registered for that name"): + with pytest.raises( + Error, + match=re.escape("Error: [Components] 'my_comp': No component registered for that name"), + ): await page.evaluate(test_js) await page.close() diff --git a/tests/test_dependency_rendering.py b/tests/test_dependency_rendering.py index 7b8b5bc9..7ce657ba 100644 --- a/tests/test_dependency_rendering.py +++ b/tests/test_dependency_rendering.py @@ -6,11 +6,12 @@ During actual rendering, the HTML is then picked up by the JS-side dependency ma import re from django.template import Context, Template +from pytest_django.asserts import assertHTMLEqual, assertInHTML from django_components import Component, registry, types +from django_components.testing import djc_test -from .django_test_setup import setup_test_config -from .testutils import BaseTestCase, create_and_process_template_response +from .testutils import create_and_process_template_response, setup_test_config setup_test_config({"autodiscover": False}) @@ -108,7 +109,8 @@ class MultistyleComponent(Component): js = ["script.js", "script2.js"] -class DependencyRenderingTests(BaseTestCase): +@djc_test +class TestDependencyRendering: def test_no_dependencies_when_no_components_used(self): registry.register(name="test", component=SimpleComponent) @@ -121,16 +123,16 @@ class DependencyRenderingTests(BaseTestCase): rendered = create_and_process_template_response(template) # Dependency manager script - self.assertInHTML('', rendered, count=1) + assertInHTML('', rendered, count=1) - self.assertEqual(rendered.count("', rendered, count=1) + assertInHTML('', rendered, count=1) - self.assertEqual(rendered.count("', rendered, count=1) + assertInHTML('', rendered, count=1) - self.assertEqual(rendered.count(''), 1) # Media.css - self.assertEqual(rendered.count("') == 1 # Media.css + assert rendered.count(" {"loadedCssUrls": ["c3R5bGUuY3Nz"], @@ -214,16 +216,16 @@ class DependencyRenderingTests(BaseTestCase): rendered = create_and_process_template_response(template) # Dependency manager script - self.assertInHTML('', rendered, count=1) + assertInHTML('', rendered, count=1) - self.assertEqual(rendered.count(''), 1) # Media.css - self.assertEqual(rendered.count("') == 1 # Media.css + assert rendered.count(" {"loadedCssUrls": ["c3R5bGUuY3Nz"], @@ -248,7 +250,7 @@ class DependencyRenderingTests(BaseTestCase): template = Template(template_str) rendered = create_and_process_template_response(template) - self.assertNotIn("_RENDERED", rendered) + assert "_RENDERED" not in rendered def test_single_component_css_dependencies(self): registry.register(name="test", component=SimpleComponent) @@ -261,13 +263,13 @@ class DependencyRenderingTests(BaseTestCase): rendered = create_and_process_template_response(template) # Dependency manager script - NOT present - self.assertInHTML('', rendered, count=0) + assertInHTML('', rendered, count=0) - self.assertEqual(rendered.count("'), 1) # Media.css + assert rendered.count('') == 1 # Media.css def test_single_component_js_dependencies(self): registry.register(name="test", component=SimpleComponent) @@ -280,16 +282,16 @@ class DependencyRenderingTests(BaseTestCase): rendered = create_and_process_template_response(template) # Dependency manager script - self.assertInHTML('', rendered, count=1) + assertInHTML('', rendered, count=1) # CSS NOT included - self.assertEqual(rendered.count(" {"loadedCssUrls": ["c3R5bGUuY3Nz"], @@ -316,14 +318,14 @@ class DependencyRenderingTests(BaseTestCase): rendered = create_and_process_template_response(template) # Dependency manager script - self.assertInHTML('', rendered, count=1) + assertInHTML('', rendered, count=1) - self.assertEqual(rendered.count(" @@ -333,7 +335,7 @@ class DependencyRenderingTests(BaseTestCase): ) # Media.js - self.assertInHTML( + assertInHTML( """ @@ -347,7 +349,7 @@ class DependencyRenderingTests(BaseTestCase): # `c3R5bGUyLmNzcw==` -> `style2.css` # `c2NyaXB0Lmpz` -> `script.js` # `c2NyaXB0Mi5qcw==` -> `script2.js` - self.assertInHTML( + assertInHTML( """ ', rendered, count=1) + assertInHTML('', rendered, count=1) - self.assertEqual(rendered.count("', rendered, count=1) + assertInHTML('', rendered, count=1) - self.assertEqual(rendered.count(".my-class { color: red; } @@ -424,7 +426,7 @@ class DependencyRenderingTests(BaseTestCase): # - "style.css", "style2.css" (from SimpleComponentNested) # - "style.css" (from SimpleComponent inside SimpleComponentNested) # - "xyz1.css" (from OtherComponent inserted into SimpleComponentNested) - self.assertInHTML( + assertInHTML( """ @@ -439,7 +441,7 @@ class DependencyRenderingTests(BaseTestCase): # - "script2.js" (from SimpleComponentNested) # - "script.js" (from SimpleComponent inside SimpleComponentNested) # - "xyz1.js" (from OtherComponent inserted into SimpleComponentNested) - self.assertInHTML( + assertInHTML( """ @@ -462,7 +464,7 @@ class DependencyRenderingTests(BaseTestCase): # `c2NyaXB0Lmpz` -> `script.js` # `c2NyaXB0Mi5qcw==` -> `script2.js` # `eHl6MS5qcw==` -> `xyz1.js` - self.assertInHTML( + assertInHTML( """