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
class MockRenderer(component_framework.core.renderer.Renderer):
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>
def render(self, template_name: str, context: dict) -> str:
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_name attribute.
  • context: The dict returned by Component.get_context().
Returns:

An HTML-like string of the form <mock template="name">{"state": ...}</mock>.

def make_component( name: str | None = None, **state_defaults: Any) -> type[component_framework.core.Component]:
 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_name set to "<name>.html" (or "anonymous.html" when name is None).
  • initialises self.state with state_defaults in its mount() 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.Component subclass.

Example::

Counter = make_component("counter", count=0)
comp = Counter()
comp.renderer = MockRenderer()
result = comp.dispatch()
assert result["state"]["count"] == 0
def register_test_component( reg: component_framework.core.ComponentRegistry, name: str, component_cls: type[component_framework.core.Component]) -> type[component_framework.core.Component]:
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:
Returns:

The same component_cls, unchanged.

class IsolatedRegistry:
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:
class ComponentTestCase:
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:

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
component_class: type[component_framework.core.Component]
renderer_class: type[component_framework.core.Renderer] = <class 'MockRenderer'>
def setup_method(self) -> None:
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.

def teardown_method(self) -> None:
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.

def mount(self, **params: Any) -> dict:
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).

def dispatch( self, event: str, payload: dict | None = None, state: dict | None = None, **params: Any) -> dict:
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 None and 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().

def assert_state(self, **expected: Any) -> None:
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() or dispatch() has not been called yet.
def assert_renders(self, substring: str) -> None:
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() or dispatch() has not been called yet.
@pytest.fixture
def isolated_registry() -> Generator[component_framework.core.ComponentRegistry, None, None]:
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()
@pytest.fixture
def mock_renderer() -> Generator[MockRenderer, None, None]:
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