component_framework.testing
Batteries-included testing toolkit for component-framework.
Provides helpers for testing components without HTTP, including:
- ComponentTestCase: pytest-compatible base class
- MockRenderer: simple renderer returning deterministic HTML
- make_component: factory for anonymous test components
- register_test_component: register components with a test registry
- IsolatedRegistry: context manager for per-test registry isolation
- isolated_registry / mock_renderer: pytest fixtures
Usage::
from component_framework.testing import ComponentTestCase, make_component
Counter = make_component("counter", count=0)
class TestCounter(ComponentTestCase):
component_class = Counter
def test_initial_state(self):
result = self.mount()
assert result["state"]["count"] == 0
1""" 2Batteries-included testing toolkit for component-framework. 3 4Provides helpers for testing components without HTTP, including: 5- ComponentTestCase: pytest-compatible base class 6- MockRenderer: simple renderer returning deterministic HTML 7- make_component: factory for anonymous test components 8- register_test_component: register components with a test registry 9- IsolatedRegistry: context manager for per-test registry isolation 10- isolated_registry / mock_renderer: pytest fixtures 11 12Usage:: 13 14 from component_framework.testing import ComponentTestCase, make_component 15 16 Counter = make_component("counter", count=0) 17 18 class TestCounter(ComponentTestCase): 19 component_class = Counter 20 21 def test_initial_state(self): 22 result = self.mount() 23 assert result["state"]["count"] == 0 24""" 25 26from __future__ import annotations 27 28import json 29import sys 30from collections.abc import Generator 31from typing import Any # noqa: UP035 32 33import pytest 34 35from component_framework.core.component import Component 36from component_framework.core.registry import ComponentRegistry 37from component_framework.core.renderer import Renderer 38 39# --------------------------------------------------------------------------- 40# MockRenderer 41# --------------------------------------------------------------------------- 42 43 44class MockRenderer(Renderer): 45 """ 46 Simple renderer for tests. 47 48 Returns a predictable string that embeds the template name and a JSON 49 representation of the full context so tests can inspect rendered output 50 without needing a real template engine. 51 52 Format:: 53 54 <mock template="<template_name>"><json context></mock> 55 """ 56 57 def render(self, template_name: str, context: dict) -> str: 58 """ 59 Render *template_name* with *context* to a deterministic HTML string. 60 61 Args: 62 template_name: The component's ``template_name`` attribute. 63 context: The dict returned by ``Component.get_context()``. 64 65 Returns: 66 An HTML-like string of the form 67 ``<mock template="name">{"state": ...}</mock>``. 68 """ 69 payload = json.dumps(context, default=str) 70 return f'<mock template="{template_name}">{payload}</mock>' 71 72 73# --------------------------------------------------------------------------- 74# make_component 75# --------------------------------------------------------------------------- 76 77 78def make_component(name: str | None = None, **state_defaults: Any) -> type[Component]: 79 """ 80 Factory that creates a minimal anonymous :class:`~component_framework.core.component.Component` 81 subclass suitable for unit tests. 82 83 The returned class: 84 85 * has ``template_name`` set to ``"<name>.html"`` (or ``"anonymous.html"`` 86 when *name* is ``None``). 87 * initialises ``self.state`` with *state_defaults* in its ``mount()`` 88 method so tests can inspect defaults immediately after mounting. 89 90 Args: 91 name: Optional logical name used to derive ``template_name``. 92 **state_defaults: Initial state key/value pairs populated in 93 ``mount()``. 94 95 Returns: 96 A new :class:`~component_framework.core.component.Component` subclass. 97 98 Example:: 99 100 Counter = make_component("counter", count=0) 101 comp = Counter() 102 comp.renderer = MockRenderer() 103 result = comp.dispatch() 104 assert result["state"]["count"] == 0 105 """ 106 template = f"{name}.html" if name else "anonymous.html" 107 defaults = dict(state_defaults) 108 109 # Capture in a closure so the generated class does not share mutable state 110 # across multiple calls to make_component. 111 def _mount(self: Component) -> None: 112 super(type(self), self).mount() # type: ignore[misc] 113 self.state.update(defaults) 114 115 component_cls: type[Component] = type( 116 name.capitalize() if name else "AnonymousComponent", 117 (Component,), 118 { 119 "template_name": template, 120 "mount": _mount, 121 }, 122 ) 123 return component_cls 124 125 126# --------------------------------------------------------------------------- 127# register_test_component 128# --------------------------------------------------------------------------- 129 130 131def register_test_component( 132 reg: ComponentRegistry, 133 name: str, 134 component_cls: type[Component], 135) -> type[Component]: 136 """ 137 Register *component_cls* under *name* in *reg* and return the class. 138 139 This is a thin convenience wrapper around 140 :meth:`~component_framework.core.registry.ComponentRegistry.register` for 141 use in test helpers where the decorator syntax is inconvenient. 142 143 Args: 144 reg: The :class:`~component_framework.core.registry.ComponentRegistry` 145 to register into. 146 name: Name to register the component under. 147 component_cls: The component class to register. 148 149 Returns: 150 The same *component_cls*, unchanged. 151 """ 152 reg.register(name)(component_cls) 153 return component_cls 154 155 156# --------------------------------------------------------------------------- 157# IsolatedRegistry 158# --------------------------------------------------------------------------- 159 160 161class IsolatedRegistry: 162 """ 163 Context manager that gives each test a fresh 164 :class:`~component_framework.core.registry.ComponentRegistry` and restores 165 the original global registry on exit. 166 167 The fresh registry is also accessible as an attribute so it can be used 168 directly:: 169 170 with IsolatedRegistry() as reg: 171 reg.register("foo")(MyComponent) 172 assert reg.get("foo") is MyComponent 173 174 It can also be used as a pytest fixture (see :func:`isolated_registry`). 175 176 Attributes: 177 registry: The temporary :class:`~component_framework.core.registry.ComponentRegistry` 178 created on entry. 179 """ 180 181 def __init__(self) -> None: 182 self.registry: ComponentRegistry = ComponentRegistry() 183 self._original_registry: ComponentRegistry | None = None 184 185 @staticmethod 186 def _registry_module(): # type: ignore[return] 187 """ 188 Return the actual ``component_framework.core.registry`` module object. 189 190 We must go through ``sys.modules`` rather than a bare 191 ``import component_framework.core.registry`` because the parent 192 package's ``__init__.py`` imports the *instance* named ``registry`` 193 and stores it as an attribute of the ``core`` package, which shadows 194 the sub-module when accessed via dotted attribute lookup after the 195 package has been imported. 196 """ 197 return sys.modules["component_framework.core.registry"] 198 199 def __enter__(self) -> ComponentRegistry: 200 """ 201 Swap the global ``registry`` with a fresh instance and return it. 202 203 Returns: 204 A new, empty :class:`~component_framework.core.registry.ComponentRegistry`. 205 """ 206 _reg_mod = self._registry_module() 207 self._original_registry = _reg_mod.registry 208 _reg_mod.registry = self.registry 209 return self.registry 210 211 def __exit__(self, *_: object) -> None: 212 """Restore the original global registry regardless of exceptions.""" 213 _reg_mod = self._registry_module() 214 if self._original_registry is not None: 215 _reg_mod.registry = self._original_registry 216 self._original_registry = None 217 218 219# --------------------------------------------------------------------------- 220# ComponentTestCase 221# --------------------------------------------------------------------------- 222 223 224class ComponentTestCase: 225 """ 226 pytest-compatible base helper for testing components without HTTP. 227 228 Subclasses must set :attr:`component_class`. Optionally override 229 :attr:`renderer_class` to use a different renderer. 230 231 A :class:`MockRenderer` is installed on :attr:`component_class` during 232 :meth:`setup_method` and removed (restored) in :meth:`teardown_method`. 233 234 Attributes: 235 component_class: The :class:`~component_framework.core.component.Component` 236 subclass under test. **Must** be set by the subclass. 237 renderer_class: Renderer class to install before each test. 238 Defaults to :class:`MockRenderer`. 239 240 Example:: 241 242 class TestCounter(ComponentTestCase): 243 component_class = CounterComponent 244 245 def test_initial_state(self): 246 result = self.mount() 247 assert result["state"]["count"] == 0 248 249 def test_increment(self): 250 result = self.dispatch("increment", {"amount": 1}) 251 assert result["state"]["count"] == 1 252 """ 253 254 component_class: type[Component] 255 renderer_class: type[Renderer] = MockRenderer 256 257 # The last dispatch result, kept so assert_state / assert_renders work 258 # without callers needing to thread the result through. 259 _last_result: dict[str, Any] | None = None 260 261 def setup_method(self) -> None: 262 """ 263 Install the renderer and reset per-test state. 264 265 Called automatically by pytest before each test method. 266 """ 267 self._old_renderer = Component.renderer 268 Component.renderer = self.renderer_class() 269 self._last_result = None 270 271 def teardown_method(self) -> None: 272 """ 273 Restore the original renderer after each test method. 274 275 Called automatically by pytest after each test method. 276 """ 277 Component.renderer = self._old_renderer 278 279 # ------------------------------------------------------------------ 280 # Public helpers 281 # ------------------------------------------------------------------ 282 283 def mount(self, **params: Any) -> dict: 284 """ 285 Instantiate :attr:`component_class` with *params* and call 286 ``dispatch()`` without an event (mount path). 287 288 Args: 289 **params: Keyword arguments forwarded to the component constructor. 290 291 Returns: 292 The dict returned by 293 :meth:`~component_framework.core.component.Component.dispatch` 294 (keys: ``html``, ``state``, ``component_id``). 295 """ 296 comp = self.component_class(**params) 297 self._last_result = comp.dispatch() 298 return self._last_result 299 300 def dispatch( 301 self, 302 event: str, 303 payload: dict | None = None, 304 state: dict | None = None, 305 **params: Any, 306 ) -> dict: 307 """ 308 Instantiate :attr:`component_class` with *params*, optionally hydrate 309 with *state*, then dispatch *event* with *payload*. 310 311 If *state* is not supplied and a previous call to :meth:`mount` or 312 :meth:`dispatch` was made in this test, its result's state is reused 313 automatically so tests can chain calls naturally. 314 315 Args: 316 event: Event name (e.g. ``"increment"``). 317 payload: Event payload dict. Defaults to ``{}``. 318 state: State to hydrate with before dispatching. If ``None`` and 319 a previous result exists, that result's state is used. 320 **params: Keyword arguments forwarded to the component constructor. 321 322 Returns: 323 The dict returned by 324 :meth:`~component_framework.core.component.Component.dispatch`. 325 """ 326 if state is None and self._last_result is not None: 327 state = self._last_result.get("state") 328 329 comp = self.component_class(**params) 330 self._last_result = comp.dispatch(event=event, payload=payload or {}, state=state) 331 return self._last_result 332 333 def assert_state(self, **expected: Any) -> None: 334 """ 335 Assert that the last dispatch result's ``state`` contains all 336 *expected* key/value pairs. 337 338 Args: 339 **expected: Expected state keys and values. 340 341 Raises: 342 AssertionError: If any key is missing or has an unexpected value. 343 RuntimeError: If :meth:`mount` or :meth:`dispatch` has not been 344 called yet. 345 """ 346 if self._last_result is None: 347 raise RuntimeError("Call mount() or dispatch() before assert_state()") 348 349 state = self._last_result.get("state", {}) 350 for key, value in expected.items(): 351 assert key in state, f"Key {key!r} not found in state {state!r}" 352 assert state[key] == value, f"State[{key!r}]: expected {value!r}, got {state[key]!r}" 353 354 def assert_renders(self, substring: str) -> None: 355 """ 356 Assert that the last dispatch result's ``html`` output contains 357 *substring*. 358 359 Args: 360 substring: The string to look for in the rendered HTML. 361 362 Raises: 363 AssertionError: If *substring* is not found in the HTML. 364 RuntimeError: If :meth:`mount` or :meth:`dispatch` has not been 365 called yet. 366 """ 367 if self._last_result is None: 368 raise RuntimeError("Call mount() or dispatch() before assert_renders()") 369 370 html = self._last_result.get("html", "") 371 assert substring in html, f"{substring!r} not found in HTML:\n{html}" 372 373 374# --------------------------------------------------------------------------- 375# pytest fixtures 376# --------------------------------------------------------------------------- 377 378 379@pytest.fixture 380def isolated_registry() -> Generator[ComponentRegistry, None, None]: 381 """ 382 Pytest fixture that yields a fresh 383 :class:`~component_framework.core.registry.ComponentRegistry` for the 384 duration of a test, then restores the original global registry. 385 386 Usage:: 387 388 def test_something(isolated_registry): 389 isolated_registry.register("foo")(MyComponent) 390 assert "foo" in isolated_registry.list() 391 """ 392 with IsolatedRegistry() as reg: 393 yield reg 394 395 396@pytest.fixture 397def mock_renderer() -> Generator[MockRenderer, None, None]: 398 """ 399 Pytest fixture that installs a :class:`MockRenderer` on 400 :class:`~component_framework.core.component.Component` for the duration of 401 a test, then restores the original renderer. 402 403 Usage:: 404 405 def test_render(mock_renderer): 406 comp = MyComponent() 407 comp.mount() 408 html = comp.render() 409 assert "my_template.html" in html 410 """ 411 old = Component.renderer 412 renderer = MockRenderer() 413 Component.renderer = renderer 414 yield renderer 415 Component.renderer = old
45class MockRenderer(Renderer): 46 """ 47 Simple renderer for tests. 48 49 Returns a predictable string that embeds the template name and a JSON 50 representation of the full context so tests can inspect rendered output 51 without needing a real template engine. 52 53 Format:: 54 55 <mock template="<template_name>"><json context></mock> 56 """ 57 58 def render(self, template_name: str, context: dict) -> str: 59 """ 60 Render *template_name* with *context* to a deterministic HTML string. 61 62 Args: 63 template_name: The component's ``template_name`` attribute. 64 context: The dict returned by ``Component.get_context()``. 65 66 Returns: 67 An HTML-like string of the form 68 ``<mock template="name">{"state": ...}</mock>``. 69 """ 70 payload = json.dumps(context, default=str) 71 return f'<mock template="{template_name}">{payload}</mock>'
Simple renderer for tests.
Returns a predictable string that embeds the template name and a JSON representation of the full context so tests can inspect rendered output without needing a real template engine.
Format::
<mock template="<template_name>"><json context></mock>
58 def render(self, template_name: str, context: dict) -> str: 59 """ 60 Render *template_name* with *context* to a deterministic HTML string. 61 62 Args: 63 template_name: The component's ``template_name`` attribute. 64 context: The dict returned by ``Component.get_context()``. 65 66 Returns: 67 An HTML-like string of the form 68 ``<mock template="name">{"state": ...}</mock>``. 69 """ 70 payload = json.dumps(context, default=str) 71 return f'<mock template="{template_name}">{payload}</mock>'
Render template_name with context to a deterministic HTML string.
Arguments:
- template_name: The component's
template_nameattribute. - context: The dict returned by
Component.get_context().
Returns:
An HTML-like string of the form
<mock template="name">{"state": ...}</mock>.
79def make_component(name: str | None = None, **state_defaults: Any) -> type[Component]: 80 """ 81 Factory that creates a minimal anonymous :class:`~component_framework.core.component.Component` 82 subclass suitable for unit tests. 83 84 The returned class: 85 86 * has ``template_name`` set to ``"<name>.html"`` (or ``"anonymous.html"`` 87 when *name* is ``None``). 88 * initialises ``self.state`` with *state_defaults* in its ``mount()`` 89 method so tests can inspect defaults immediately after mounting. 90 91 Args: 92 name: Optional logical name used to derive ``template_name``. 93 **state_defaults: Initial state key/value pairs populated in 94 ``mount()``. 95 96 Returns: 97 A new :class:`~component_framework.core.component.Component` subclass. 98 99 Example:: 100 101 Counter = make_component("counter", count=0) 102 comp = Counter() 103 comp.renderer = MockRenderer() 104 result = comp.dispatch() 105 assert result["state"]["count"] == 0 106 """ 107 template = f"{name}.html" if name else "anonymous.html" 108 defaults = dict(state_defaults) 109 110 # Capture in a closure so the generated class does not share mutable state 111 # across multiple calls to make_component. 112 def _mount(self: Component) -> None: 113 super(type(self), self).mount() # type: ignore[misc] 114 self.state.update(defaults) 115 116 component_cls: type[Component] = type( 117 name.capitalize() if name else "AnonymousComponent", 118 (Component,), 119 { 120 "template_name": template, 121 "mount": _mount, 122 }, 123 ) 124 return component_cls
Factory that creates a minimal anonymous ~component_framework.core.component.Component
subclass suitable for unit tests.
The returned class:
- has
template_nameset to"<name>.html"(or"anonymous.html"when name isNone). - initialises
self.statewith state_defaults in itsmount()method so tests can inspect defaults immediately after mounting.
Arguments:
- name: Optional logical name used to derive
template_name. - **state_defaults: Initial state key/value pairs populated in
mount().
Returns:
A new
~component_framework.core.component.Componentsubclass.
Example::
Counter = make_component("counter", count=0)
comp = Counter()
comp.renderer = MockRenderer()
result = comp.dispatch()
assert result["state"]["count"] == 0
132def register_test_component( 133 reg: ComponentRegistry, 134 name: str, 135 component_cls: type[Component], 136) -> type[Component]: 137 """ 138 Register *component_cls* under *name* in *reg* and return the class. 139 140 This is a thin convenience wrapper around 141 :meth:`~component_framework.core.registry.ComponentRegistry.register` for 142 use in test helpers where the decorator syntax is inconvenient. 143 144 Args: 145 reg: The :class:`~component_framework.core.registry.ComponentRegistry` 146 to register into. 147 name: Name to register the component under. 148 component_cls: The component class to register. 149 150 Returns: 151 The same *component_cls*, unchanged. 152 """ 153 reg.register(name)(component_cls) 154 return component_cls
Register component_cls under name in reg and return the class.
This is a thin convenience wrapper around
~component_framework.core.registry.ComponentRegistry.register() for
use in test helpers where the decorator syntax is inconvenient.
Arguments:
- reg: The
~component_framework.core.registry.ComponentRegistryto register into. - name: Name to register the component under.
- component_cls: The component class to register.
Returns:
The same component_cls, unchanged.
162class IsolatedRegistry: 163 """ 164 Context manager that gives each test a fresh 165 :class:`~component_framework.core.registry.ComponentRegistry` and restores 166 the original global registry on exit. 167 168 The fresh registry is also accessible as an attribute so it can be used 169 directly:: 170 171 with IsolatedRegistry() as reg: 172 reg.register("foo")(MyComponent) 173 assert reg.get("foo") is MyComponent 174 175 It can also be used as a pytest fixture (see :func:`isolated_registry`). 176 177 Attributes: 178 registry: The temporary :class:`~component_framework.core.registry.ComponentRegistry` 179 created on entry. 180 """ 181 182 def __init__(self) -> None: 183 self.registry: ComponentRegistry = ComponentRegistry() 184 self._original_registry: ComponentRegistry | None = None 185 186 @staticmethod 187 def _registry_module(): # type: ignore[return] 188 """ 189 Return the actual ``component_framework.core.registry`` module object. 190 191 We must go through ``sys.modules`` rather than a bare 192 ``import component_framework.core.registry`` because the parent 193 package's ``__init__.py`` imports the *instance* named ``registry`` 194 and stores it as an attribute of the ``core`` package, which shadows 195 the sub-module when accessed via dotted attribute lookup after the 196 package has been imported. 197 """ 198 return sys.modules["component_framework.core.registry"] 199 200 def __enter__(self) -> ComponentRegistry: 201 """ 202 Swap the global ``registry`` with a fresh instance and return it. 203 204 Returns: 205 A new, empty :class:`~component_framework.core.registry.ComponentRegistry`. 206 """ 207 _reg_mod = self._registry_module() 208 self._original_registry = _reg_mod.registry 209 _reg_mod.registry = self.registry 210 return self.registry 211 212 def __exit__(self, *_: object) -> None: 213 """Restore the original global registry regardless of exceptions.""" 214 _reg_mod = self._registry_module() 215 if self._original_registry is not None: 216 _reg_mod.registry = self._original_registry 217 self._original_registry = None
Context manager that gives each test a fresh
~component_framework.core.registry.ComponentRegistry and restores
the original global registry on exit.
The fresh registry is also accessible as an attribute so it can be used directly::
with IsolatedRegistry() as reg:
reg.register("foo")(MyComponent)
assert reg.get("foo") is MyComponent
It can also be used as a pytest fixture (see isolated_registry()).
Attributes:
- registry: The temporary
~component_framework.core.registry.ComponentRegistrycreated on entry.
225class ComponentTestCase: 226 """ 227 pytest-compatible base helper for testing components without HTTP. 228 229 Subclasses must set :attr:`component_class`. Optionally override 230 :attr:`renderer_class` to use a different renderer. 231 232 A :class:`MockRenderer` is installed on :attr:`component_class` during 233 :meth:`setup_method` and removed (restored) in :meth:`teardown_method`. 234 235 Attributes: 236 component_class: The :class:`~component_framework.core.component.Component` 237 subclass under test. **Must** be set by the subclass. 238 renderer_class: Renderer class to install before each test. 239 Defaults to :class:`MockRenderer`. 240 241 Example:: 242 243 class TestCounter(ComponentTestCase): 244 component_class = CounterComponent 245 246 def test_initial_state(self): 247 result = self.mount() 248 assert result["state"]["count"] == 0 249 250 def test_increment(self): 251 result = self.dispatch("increment", {"amount": 1}) 252 assert result["state"]["count"] == 1 253 """ 254 255 component_class: type[Component] 256 renderer_class: type[Renderer] = MockRenderer 257 258 # The last dispatch result, kept so assert_state / assert_renders work 259 # without callers needing to thread the result through. 260 _last_result: dict[str, Any] | None = None 261 262 def setup_method(self) -> None: 263 """ 264 Install the renderer and reset per-test state. 265 266 Called automatically by pytest before each test method. 267 """ 268 self._old_renderer = Component.renderer 269 Component.renderer = self.renderer_class() 270 self._last_result = None 271 272 def teardown_method(self) -> None: 273 """ 274 Restore the original renderer after each test method. 275 276 Called automatically by pytest after each test method. 277 """ 278 Component.renderer = self._old_renderer 279 280 # ------------------------------------------------------------------ 281 # Public helpers 282 # ------------------------------------------------------------------ 283 284 def mount(self, **params: Any) -> dict: 285 """ 286 Instantiate :attr:`component_class` with *params* and call 287 ``dispatch()`` without an event (mount path). 288 289 Args: 290 **params: Keyword arguments forwarded to the component constructor. 291 292 Returns: 293 The dict returned by 294 :meth:`~component_framework.core.component.Component.dispatch` 295 (keys: ``html``, ``state``, ``component_id``). 296 """ 297 comp = self.component_class(**params) 298 self._last_result = comp.dispatch() 299 return self._last_result 300 301 def dispatch( 302 self, 303 event: str, 304 payload: dict | None = None, 305 state: dict | None = None, 306 **params: Any, 307 ) -> dict: 308 """ 309 Instantiate :attr:`component_class` with *params*, optionally hydrate 310 with *state*, then dispatch *event* with *payload*. 311 312 If *state* is not supplied and a previous call to :meth:`mount` or 313 :meth:`dispatch` was made in this test, its result's state is reused 314 automatically so tests can chain calls naturally. 315 316 Args: 317 event: Event name (e.g. ``"increment"``). 318 payload: Event payload dict. Defaults to ``{}``. 319 state: State to hydrate with before dispatching. If ``None`` and 320 a previous result exists, that result's state is used. 321 **params: Keyword arguments forwarded to the component constructor. 322 323 Returns: 324 The dict returned by 325 :meth:`~component_framework.core.component.Component.dispatch`. 326 """ 327 if state is None and self._last_result is not None: 328 state = self._last_result.get("state") 329 330 comp = self.component_class(**params) 331 self._last_result = comp.dispatch(event=event, payload=payload or {}, state=state) 332 return self._last_result 333 334 def assert_state(self, **expected: Any) -> None: 335 """ 336 Assert that the last dispatch result's ``state`` contains all 337 *expected* key/value pairs. 338 339 Args: 340 **expected: Expected state keys and values. 341 342 Raises: 343 AssertionError: If any key is missing or has an unexpected value. 344 RuntimeError: If :meth:`mount` or :meth:`dispatch` has not been 345 called yet. 346 """ 347 if self._last_result is None: 348 raise RuntimeError("Call mount() or dispatch() before assert_state()") 349 350 state = self._last_result.get("state", {}) 351 for key, value in expected.items(): 352 assert key in state, f"Key {key!r} not found in state {state!r}" 353 assert state[key] == value, f"State[{key!r}]: expected {value!r}, got {state[key]!r}" 354 355 def assert_renders(self, substring: str) -> None: 356 """ 357 Assert that the last dispatch result's ``html`` output contains 358 *substring*. 359 360 Args: 361 substring: The string to look for in the rendered HTML. 362 363 Raises: 364 AssertionError: If *substring* is not found in the HTML. 365 RuntimeError: If :meth:`mount` or :meth:`dispatch` has not been 366 called yet. 367 """ 368 if self._last_result is None: 369 raise RuntimeError("Call mount() or dispatch() before assert_renders()") 370 371 html = self._last_result.get("html", "") 372 assert substring in html, f"{substring!r} not found in HTML:\n{html}"
pytest-compatible base helper for testing components without HTTP.
Subclasses must set component_class. Optionally override
renderer_class to use a different renderer.
A MockRenderer is installed on component_class during
setup_method() and removed (restored) in teardown_method().
Attributes:
- component_class: The
~component_framework.core.component.Componentsubclass under test. Must be set by the subclass. - renderer_class: Renderer class to install before each test.
Defaults to
MockRenderer.
Example::
class TestCounter(ComponentTestCase):
component_class = CounterComponent
def test_initial_state(self):
result = self.mount()
assert result["state"]["count"] == 0
def test_increment(self):
result = self.dispatch("increment", {"amount": 1})
assert result["state"]["count"] == 1
262 def setup_method(self) -> None: 263 """ 264 Install the renderer and reset per-test state. 265 266 Called automatically by pytest before each test method. 267 """ 268 self._old_renderer = Component.renderer 269 Component.renderer = self.renderer_class() 270 self._last_result = None
Install the renderer and reset per-test state.
Called automatically by pytest before each test method.
272 def teardown_method(self) -> None: 273 """ 274 Restore the original renderer after each test method. 275 276 Called automatically by pytest after each test method. 277 """ 278 Component.renderer = self._old_renderer
Restore the original renderer after each test method.
Called automatically by pytest after each test method.
284 def mount(self, **params: Any) -> dict: 285 """ 286 Instantiate :attr:`component_class` with *params* and call 287 ``dispatch()`` without an event (mount path). 288 289 Args: 290 **params: Keyword arguments forwarded to the component constructor. 291 292 Returns: 293 The dict returned by 294 :meth:`~component_framework.core.component.Component.dispatch` 295 (keys: ``html``, ``state``, ``component_id``). 296 """ 297 comp = self.component_class(**params) 298 self._last_result = comp.dispatch() 299 return self._last_result
Instantiate component_class with params and call
dispatch() without an event (mount path).
Arguments:
- **params: Keyword arguments forwarded to the component constructor.
Returns:
The dict returned by
~component_framework.core.component.Component.dispatch()(keys:html,state,component_id).
301 def dispatch( 302 self, 303 event: str, 304 payload: dict | None = None, 305 state: dict | None = None, 306 **params: Any, 307 ) -> dict: 308 """ 309 Instantiate :attr:`component_class` with *params*, optionally hydrate 310 with *state*, then dispatch *event* with *payload*. 311 312 If *state* is not supplied and a previous call to :meth:`mount` or 313 :meth:`dispatch` was made in this test, its result's state is reused 314 automatically so tests can chain calls naturally. 315 316 Args: 317 event: Event name (e.g. ``"increment"``). 318 payload: Event payload dict. Defaults to ``{}``. 319 state: State to hydrate with before dispatching. If ``None`` and 320 a previous result exists, that result's state is used. 321 **params: Keyword arguments forwarded to the component constructor. 322 323 Returns: 324 The dict returned by 325 :meth:`~component_framework.core.component.Component.dispatch`. 326 """ 327 if state is None and self._last_result is not None: 328 state = self._last_result.get("state") 329 330 comp = self.component_class(**params) 331 self._last_result = comp.dispatch(event=event, payload=payload or {}, state=state) 332 return self._last_result
Instantiate component_class with params, optionally hydrate
with state, then dispatch event with payload.
If state is not supplied and a previous call to mount() or
dispatch() was made in this test, its result's state is reused
automatically so tests can chain calls naturally.
Arguments:
- event: Event name (e.g.
"increment"). - payload: Event payload dict. Defaults to
{}. - state: State to hydrate with before dispatching. If
Noneand a previous result exists, that result's state is used. - **params: Keyword arguments forwarded to the component constructor.
Returns:
The dict returned by
~component_framework.core.component.Component.dispatch().
334 def assert_state(self, **expected: Any) -> None: 335 """ 336 Assert that the last dispatch result's ``state`` contains all 337 *expected* key/value pairs. 338 339 Args: 340 **expected: Expected state keys and values. 341 342 Raises: 343 AssertionError: If any key is missing or has an unexpected value. 344 RuntimeError: If :meth:`mount` or :meth:`dispatch` has not been 345 called yet. 346 """ 347 if self._last_result is None: 348 raise RuntimeError("Call mount() or dispatch() before assert_state()") 349 350 state = self._last_result.get("state", {}) 351 for key, value in expected.items(): 352 assert key in state, f"Key {key!r} not found in state {state!r}" 353 assert state[key] == value, f"State[{key!r}]: expected {value!r}, got {state[key]!r}"
Assert that the last dispatch result's state contains all
expected key/value pairs.
Arguments:
- **expected: Expected state keys and values.
Raises:
- AssertionError: If any key is missing or has an unexpected value.
- RuntimeError: If
mount()ordispatch()has not been called yet.
355 def assert_renders(self, substring: str) -> None: 356 """ 357 Assert that the last dispatch result's ``html`` output contains 358 *substring*. 359 360 Args: 361 substring: The string to look for in the rendered HTML. 362 363 Raises: 364 AssertionError: If *substring* is not found in the HTML. 365 RuntimeError: If :meth:`mount` or :meth:`dispatch` has not been 366 called yet. 367 """ 368 if self._last_result is None: 369 raise RuntimeError("Call mount() or dispatch() before assert_renders()") 370 371 html = self._last_result.get("html", "") 372 assert substring in html, f"{substring!r} not found in HTML:\n{html}"
Assert that the last dispatch result's html output contains
substring.
Arguments:
- substring: The string to look for in the rendered HTML.
Raises:
- AssertionError: If substring is not found in the HTML.
- RuntimeError: If
mount()ordispatch()has not been called yet.
380@pytest.fixture 381def isolated_registry() -> Generator[ComponentRegistry, None, None]: 382 """ 383 Pytest fixture that yields a fresh 384 :class:`~component_framework.core.registry.ComponentRegistry` for the 385 duration of a test, then restores the original global registry. 386 387 Usage:: 388 389 def test_something(isolated_registry): 390 isolated_registry.register("foo")(MyComponent) 391 assert "foo" in isolated_registry.list() 392 """ 393 with IsolatedRegistry() as reg: 394 yield reg
Pytest fixture that yields a fresh
~component_framework.core.registry.ComponentRegistry for the
duration of a test, then restores the original global registry.
Usage::
def test_something(isolated_registry):
isolated_registry.register("foo")(MyComponent)
assert "foo" in isolated_registry.list()
397@pytest.fixture 398def mock_renderer() -> Generator[MockRenderer, None, None]: 399 """ 400 Pytest fixture that installs a :class:`MockRenderer` on 401 :class:`~component_framework.core.component.Component` for the duration of 402 a test, then restores the original renderer. 403 404 Usage:: 405 406 def test_render(mock_renderer): 407 comp = MyComponent() 408 comp.mount() 409 html = comp.render() 410 assert "my_template.html" in html 411 """ 412 old = Component.renderer 413 renderer = MockRenderer() 414 Component.renderer = renderer 415 yield renderer 416 Component.renderer = old
Pytest fixture that installs a MockRenderer on
~component_framework.core.component.Component for the duration of
a test, then restores the original renderer.
Usage::
def test_render(mock_renderer):
comp = MyComponent()
comp.mount()
html = comp.render()
assert "my_template.html" in html