Skip to content

Testing Utilities

Reference for the cosalette.testing package — test doubles, factories, and pytest fixtures for testing cosalette applications.

Looking for recipes?

See the Test Your Application guide for per-archetype usage patterns, and the Testing Strategy concept for the design rationale.

Test Harness

cosalette.testing.AppHarness dataclass

AppHarness(
    app: App,
    mqtt: MockMqttClient,
    clock: ClockPort,
    settings: Settings,
    shutdown_event: Event,
    run_periodic: bool = False,
)

Test harness wrapping App with pre-configured test doubles.

Provides unified access to App, MockMqttClient, FakeClock, Settings, and a shutdown Event — eliminating boilerplate in integration-style tests.

Usage::

harness = AppHarness.create()

@harness.app.device("sensor")
async def sensor(ctx):
    ...

# Run with auto-shutdown after device_called event:
await harness.run()
See Also

ADR-007 for testing strategy decisions.

create classmethod

create(
    *,
    name: str = "testapp",
    version: str = "1.0.0",
    dry_run: bool = False,
    lifespan: LifespanFunc | None = None,
    store: Store | None = None,
    run_periodic: bool = False,
    error_type_map: dict[type[Exception], str]
    | None = None,
    disclose_messages_for: frozenset[type[Exception]]
    | None = None,
    retained_cleanup_snapshot_key: SecretStr | None = None,
    clock: ClockPort | None = None,
    **settings_overrides: Any,
) -> Self

Create a harness with fresh test doubles.

Parameters:

Name Type Description Default
name str

App name.

'testapp'
version str

App version.

'1.0.0'
dry_run bool

When True, forward to App for dry-run adapter variants.

False
lifespan LifespanFunc | None

Optional lifespan context manager forwarded to :class:App.

None
store Store | None

Optional :class:Store backend for device persistence.

None
run_periodic bool

When True, periodic tasks will be started; when False, they will be suppressed for testing.

False
error_type_map dict[type[Exception], str] | None

Optional app-level exception → error_type map forwarded to :class:App, so tests can exercise the LEAK-01 targeted opt-in end-to-end (see ADR-011).

None
disclose_messages_for frozenset[type[Exception]] | None

Optional app-level message-disclosure set forwarded to :class:App, so tests can exercise the F-DP1 decoupled opt-in end-to-end (see ADR-061).

None
retained_cleanup_snapshot_key SecretStr | None

Optional ADR-048 retained-cleanup snapshot HMAC signing key forwarded to :class:App, so tests can exercise the F-DP3 opt-in signing/verification end-to-end (see ADR-063).

None
clock ClockPort | None

Optional :class:ClockPort double to drive the harness. Defaults to :class:FakeClock, whose sleep() self-completes. Pass a :class:ManualClock to gate runner sleeps and assert that a scheduled tick did not fire — see ADR-071.

None
**settings_overrides Any

Forwarded to :func:make_settings.

{}

Returns:

Type Description
Self

A fully wired :class:AppHarness ready for test use.

Raises:

Type Description
TypeError

If a settings_overrides key names neither a Settings field nor a supported runtime kwarg — a mistyped or unsupported keyword (e.g. clok=) fails loudly instead of being silently ignored.

Source code in packages/src/cosalette/testing/_harness.py
@classmethod
def create(
    cls,
    *,
    name: str = "testapp",
    version: str = "1.0.0",
    dry_run: bool = False,
    lifespan: LifespanFunc | None = None,
    store: Store | None = None,
    run_periodic: bool = False,
    error_type_map: dict[type[Exception], str] | None = None,
    disclose_messages_for: frozenset[type[Exception]] | None = None,
    retained_cleanup_snapshot_key: SecretStr | None = None,
    clock: ClockPort | None = None,
    **settings_overrides: Any,
) -> Self:
    """Create a harness with fresh test doubles.

    Args:
        name: App name.
        version: App version.
        dry_run: When True, forward to App for dry-run adapter variants.
        lifespan: Optional lifespan context manager forwarded to
            :class:`App`.
        store: Optional :class:`Store` backend for device persistence.
        run_periodic: When True, periodic tasks will be started; when False,
            they will be suppressed for testing.
        error_type_map: Optional app-level exception → ``error_type`` map
            forwarded to :class:`App`, so tests can exercise the LEAK-01
            targeted opt-in end-to-end (see ADR-011).
        disclose_messages_for: Optional app-level message-disclosure set
            forwarded to :class:`App`, so tests can exercise the F-DP1
            decoupled opt-in end-to-end (see ADR-061).
        retained_cleanup_snapshot_key: Optional ADR-048 retained-cleanup
            snapshot HMAC signing key forwarded to :class:`App`, so tests
            can exercise the F-DP3 opt-in signing/verification end-to-end
            (see ADR-063).
        clock: Optional :class:`ClockPort` double to drive the harness.
            Defaults to :class:`FakeClock`, whose ``sleep()`` self-completes.
            Pass a :class:`ManualClock` to gate runner sleeps and assert
            that a scheduled tick did *not* fire — see ADR-071.
        **settings_overrides: Forwarded to :func:`make_settings`.

    Returns:
        A fully wired :class:`AppHarness` ready for test use.

    Raises:
        TypeError: If a ``settings_overrides`` key names neither a
            ``Settings`` field nor a supported runtime kwarg — a
            mistyped or unsupported keyword (e.g. ``clok=``) fails
            loudly instead of being silently ignored.
    """
    return cls(
        app=App(
            name=name,
            version=version,
            dry_run=dry_run,
            lifespan=lifespan,
            store=store,
            error_type_map=error_type_map,
            disclose_messages_for=disclose_messages_for,
            retained_cleanup_snapshot_key=retained_cleanup_snapshot_key,
        ),
        mqtt=MockMqttClient(),
        clock=clock if clock is not None else FakeClock(),
        settings=make_settings(**settings_overrides),
        shutdown_event=asyncio.Event(),
        run_periodic=run_periodic,
    )

run async

run() -> None

Run _run_async with the harness's test doubles.

Source code in packages/src/cosalette/testing/_harness.py
async def run(self) -> None:
    """Run ``_run_async`` with the harness's test doubles."""
    periodic_backup = list(self.app._periodic)
    streams_backup = list(self.app._streams)
    if not self.run_periodic:
        self.app._periodic = []
    # Always suppress streams in harness.run() — use inject_stream() instead
    self.app._streams = []
    try:
        await self.app._run_async(
            settings=self.settings,
            shutdown_event=self.shutdown_event,
            mqtt=self.mqtt,
            clock=self.clock,
        )
    finally:
        self.app._periodic = periodic_backup
        self.app._streams = streams_backup

trigger_shutdown

trigger_shutdown() -> None

Signal the shutdown event.

Source code in packages/src/cosalette/testing/_harness.py
def trigger_shutdown(self) -> None:
    """Signal the shutdown event."""
    self.shutdown_event.set()

inject_stream async

inject_stream(
    name: str,
    *items: Any,
    shutdown: bool = True,
    ctx: DeviceContext | None = None,
    store: Store | None = None,
    providers: dict[type, Any] | None = None,
    adapters: dict[type, object] | None = None,
) -> None

Push items into a named stream handler for testing.

Finds the registered @app.stream handler by name, creates a Stream, pushes the provided items, optionally signals shutdown, and runs the handler directly (bypassing adapter lifecycle).

Parameters:

Name Type Description Default
name str

Stream handler name as registered with @app.stream.

required
*items Any

Items to push into the stream.

()
shutdown bool

When True (default), call stream.shutdown() after all items are pushed so the handler's async for loop terminates.

True
ctx DeviceContext | None

Optional :class:DeviceContext override. When None (default), a context is constructed from the harness doubles (mqtt, settings, clock, shutdown_event) so handlers can call ctx.publish_state etc. and assertions use harness.mqtt.published.

None
store Store | None

Optional :class:Store backend for persistence. When None, falls back to app._store if configured. The harness creates a :class:DeviceStore keyed by name, loads it before the handler runs, and saves it afterward.

None
providers dict[type, Any] | None

Extra DI providers merged into the provider map with the highest priority (override everything else).

None
adapters dict[type, object] | None

Concrete adapter instances injected by their concrete type into both the DI provider map and the :class:DeviceContext adapters dict. Allows stream handlers to access adapters for non-lifecycle operations without running the hardware lifecycle (open/start_scan).

None
Note

When ctx is supplied it replaces the entire :class:DeviceContext — harness doubles (mqtt, clock, shutdown_event) are not merged in. adapters are added to the DI providers map but not injected into the explicitly supplied ctx. If you need both a custom context and adapter injection, build the context with the adapters you need and pass both ctx and adapters.

Raises:

Type Description
ValueError

If no stream handler with name is registered.

TypeError

If a required dependency (e.g. :class:DeviceStore) cannot be resolved from the provider map.

Source code in packages/src/cosalette/testing/_harness.py
async def inject_stream(
    self,
    name: str,
    *items: Any,
    shutdown: bool = True,
    ctx: DeviceContext | None = None,
    store: Store | None = None,
    providers: dict[type, Any] | None = None,
    adapters: dict[type, object] | None = None,
) -> None:
    """Push items into a named stream handler for testing.

    Finds the registered @app.stream handler by name, creates a Stream,
    pushes the provided items, optionally signals shutdown, and runs the
    handler directly (bypassing adapter lifecycle).

    Args:
        name: Stream handler name as registered with @app.stream.
        *items: Items to push into the stream.
        shutdown: When True (default), call stream.shutdown() after all
            items are pushed so the handler's async for loop terminates.
        ctx: Optional :class:`DeviceContext` override.  When ``None``
            (default), a context is constructed from the harness doubles
            (mqtt, settings, clock, shutdown_event) so handlers can call
            ``ctx.publish_state`` etc. and assertions use
            ``harness.mqtt.published``.
        store: Optional :class:`Store` backend for persistence.  When
            ``None``, falls back to ``app._store`` if configured.  The
            harness creates a :class:`DeviceStore` keyed by *name*,
            loads it before the handler runs, and saves it afterward.
        providers: Extra DI providers merged into the provider map with
            the highest priority (override everything else).
        adapters: Concrete adapter instances injected by their concrete
            type into both the DI provider map and the
            :class:`DeviceContext` adapters dict.  Allows stream handlers
            to access adapters for non-lifecycle operations without
            running the hardware lifecycle (open/start_scan).

    Note:
        When *ctx* is supplied it replaces the entire :class:`DeviceContext`
        — harness doubles (mqtt, clock, shutdown_event) are not merged in.
        *adapters* are added to the DI providers map but **not** injected
        into the explicitly supplied *ctx*.  If you need both a custom
        context and adapter injection, build the context with the adapters
        you need and pass both *ctx* and *adapters*.

    Raises:
        ValueError: If no stream handler with *name* is registered.
        TypeError: If a required dependency (e.g. :class:`DeviceStore`)
            cannot be resolved from the provider map.
    """
    try:
        reg = next(r for r in self.app._streams if r.name == name)
    except StopIteration:
        msg = f"No stream handler named '{name}' found"
        raise ValueError(msg) from None

    stream: Stream[Any] = Stream()
    for item in items:
        stream.put(item)

    if shutdown:
        asyncio.create_task(
            _stream_auto_shutdown(stream), name=f"inject-shutdown:{name}"
        )

    resolved_adapters: dict[type, object] = dict(adapters) if adapters else {}
    ctx = self._make_stream_ctx(name, reg, resolved_adapters, ctx)
    device_store = await self._make_device_store(name, store, providers)
    base_providers = self._build_inject_providers(
        name, ctx, resolved_adapters, device_store, providers
    )

    try:
        await _run_stream_handler(reg, stream, base_providers, self.app._reactors)
    finally:
        # Retrieve the DeviceStore from final providers — _make_device_store
        # returns None when a pre-supplied store was passed via providers,
        # but in that case base_providers.get(DeviceStore) still returns it.
        await async_save_store_on_shutdown(base_providers.get(DeviceStore), name)

override_state

override_state(state_type: type, instance: Any) -> None

Override a @app.state factory with a pre-built test double.

Bypasses the factory entirely; instance is injected directly into the DI container at bootstrap. Call before :meth:run.

Parameters:

Name Type Description Default
state_type type

The type returned by the factory (the DI key).

required
instance Any

The test double to inject.

required

Raises:

Type Description
TypeError

If instance is not an instance of state_type.

Source code in packages/src/cosalette/testing/_harness.py
def override_state(self, state_type: type, instance: Any) -> None:
    """Override a @app.state factory with a pre-built test double.

    Bypasses the factory entirely; *instance* is injected directly
    into the DI container at bootstrap.  Call before :meth:`run`.

    Args:
        state_type: The type returned by the factory (the DI key).
        instance: The test double to inject.

    Raises:
        TypeError: If *instance* is not an instance of *state_type*.
    """
    if not isinstance(instance, state_type):
        raise TypeError(
            f"override_state: expected an instance of {state_type.__name__!r}, "
            f"got {type(instance).__name__!r}"
        )
    self.app._state_overrides[state_type] = instance

tick_periodic async

tick_periodic(name: str) -> None

Invoke one cycle of the named periodic handler (bypasses interval).

Directly calls the handler's function with injected arguments — skips the asyncio sleep so you can test the handler logic without waiting for the interval.

Parameters:

Name Type Description Default
name str

The periodic task name as registered with @app.periodic.

required

Raises:

Type Description
ValueError

if no periodic task with name exists.

Source code in packages/src/cosalette/testing/_harness.py
async def tick_periodic(self, name: str) -> None:
    """Invoke one cycle of the named periodic handler (bypasses interval).

    Directly calls the handler's function with injected arguments —
    skips the asyncio sleep so you can test the handler logic
    without waiting for the interval.

    Args:
        name: The periodic task name as registered with ``@app.periodic``.

    Raises:
        ValueError: if no periodic task with *name* exists.
    """
    from cosalette._injection import resolve_request_kwargs

    try:
        reg = next(r for r in self.app._periodic if r.name == name)
    except StopIteration:
        msg = f"No periodic task named '{name}' found"
        raise ValueError(msg) from None

    # Build a provider map matching production _build_periodic_providers:
    # settings under every Settings base class, clock, logger, state overrides
    providers: dict[type, Any] = {}
    settings = self.settings
    for cls in type(settings).__mro__:
        if isinstance(cls, type) and issubclass(cls, Settings):
            providers[cls] = settings
    providers[ClockPort] = self.clock
    providers[logging.Logger] = logging.getLogger(f"cosalette.periodic.{name}")
    providers.update(self.app._state_overrides)
    kwargs = resolve_request_kwargs(reg.injection_plan, providers)
    await reg.func(**kwargs)

published

published() -> list[tuple[str, str, bool, int]]

Return a snapshot of all MQTT messages published so far.

Returns:

Type Description
list[tuple[str, str, bool, int]]

Snapshot list of (topic, payload, retain, qos) tuples. This

list[tuple[str, str, bool, int]]

is a copy — mutating the returned list does not affect the

list[tuple[str, str, bool, int]]

class:MockMqttClient internal state.

Source code in packages/src/cosalette/testing/_harness.py
def published(self) -> list[tuple[str, str, bool, int]]:
    """Return a snapshot of all MQTT messages published so far.

    Returns:
        Snapshot list of ``(topic, payload, retain, qos)`` tuples. This
        is a copy — mutating the returned list does not affect the
        :class:`MockMqttClient` internal state.
    """
    return list(self.mqtt.published)

messages_for

messages_for(topic: str) -> list[tuple[str, bool, int]]

Return all messages published to topic.

Parameters:

Name Type Description Default
topic str

MQTT topic filter (exact match only).

required

Returns:

Type Description
list[tuple[str, bool, int]]

List of (payload, retain, qos) tuples for the given topic.

Source code in packages/src/cosalette/testing/_harness.py
def messages_for(self, topic: str) -> list[tuple[str, bool, int]]:
    """Return all messages published to *topic*.

    Args:
        topic: MQTT topic filter (exact match only).

    Returns:
        List of ``(payload, retain, qos)`` tuples for the given *topic*.
    """
    return self.mqtt.get_messages_for(topic)

last_published

last_published() -> tuple[str, str, bool, int] | None

Return the most recent MQTT publish, or None if no publishes.

Returns:

Type Description
tuple[str, str, bool, int] | None

(topic, payload, retain, qos) tuple or None.

Source code in packages/src/cosalette/testing/_harness.py
def last_published(self) -> tuple[str, str, bool, int] | None:
    """Return the most recent MQTT publish, or ``None`` if no publishes.

    Returns:
        ``(topic, payload, retain, qos)`` tuple or ``None``.
    """
    return self.mqtt.published[-1] if self.mqtt.published else None

assert_state

assert_state(
    topic: str,
    expected: dict[str, Any],
    *,
    count: int | None = None,
) -> None

Assert that topic has a retained JSON message containing expected.

Checks that at least one retained message on topic has a JSON payload that is a deep recursive superset of expected. An empty expected dict matches any retained JSON message.

Parameters:

Name Type Description Default
topic str

MQTT topic to check (exact match).

required
expected dict[str, Any]

Key/value subset that must appear in at least one payload. List-typed values in expected use exact equality, not element containment.

required
count int | None

Optional exact number of messages that must have been published to topic.

None

Raises:

Type Description
AssertionError

If no messages for topic; if count mismatches; if no payload is a superset of expected; or if the matching message was not retained.

Source code in packages/src/cosalette/testing/_harness.py
def assert_state(
    self,
    topic: str,
    expected: dict[str, Any],
    *,
    count: int | None = None,
) -> None:
    """Assert that *topic* has a retained JSON message containing *expected*.

    Checks that at least one retained message on *topic* has a JSON payload
    that is a deep recursive superset of *expected*.  An empty *expected*
    dict matches any retained JSON message.

    Args:
        topic: MQTT topic to check (exact match).
        expected: Key/value subset that must appear in at least one payload.
            List-typed values in *expected* use exact equality, not element
            containment.
        count: Optional exact number of messages that must have been
            published to *topic*.

    Raises:
        AssertionError: If no messages for *topic*; if *count* mismatches;
            if no payload is a superset of *expected*; or if the matching
            message was not retained.
    """
    messages = self.messages_for(topic)
    if not messages:
        raise AssertionError(f"No messages published to {topic!r}")
    if count is not None and len(messages) != count:
        raise AssertionError(
            f"Expected {count} message(s) to {topic!r}, got {len(messages)}"
        )
    found_retained, found_any_subset, parseable_payloads, skipped = (
        _scan_state_messages(messages, expected)
    )
    if found_retained:
        return
    if found_any_subset:
        raise AssertionError(
            f"Matching message on {topic!r} exists but was not retained "
            f"(state publications must be retained)"
        )
    skip_note = (
        f"\n({skipped} message(s) skipped — non-JSON-object payload)"
        if skipped
        else ""
    )
    raise AssertionError(
        f"No message on {topic!r} contains {expected!r}.\n"
        f"Parseable JSON-dict payloads: {parseable_payloads}{skip_note}"
    )

assert_subscribed

assert_subscribed(topic: str) -> None

Assert that topic has been subscribed to.

Parameters:

Name Type Description Default
topic str

Exact MQTT topic string to look up.

required

Raises:

Type Description
AssertionError

If topic is not in self.mqtt.subscriptions.

Source code in packages/src/cosalette/testing/_harness.py
def assert_subscribed(self, topic: str) -> None:
    """Assert that *topic* has been subscribed to.

    Args:
        topic: Exact MQTT topic string to look up.

    Raises:
        AssertionError: If *topic* is not in ``self.mqtt.subscriptions``.
    """
    if topic not in self.mqtt.subscriptions:
        raise AssertionError(
            f"Topic {topic!r} not subscribed.\n"
            f"Actual subscriptions: {self.mqtt.subscriptions!r}"
        )

assert_published

assert_published(
    topic: str,
    *,
    contains: str | None = None,
    count: int | None = None,
) -> None

Assert that topic has published messages matching criteria.

Parameters:

Name Type Description Default
topic str

MQTT topic to check (exact match).

required
contains str | None

Optional substring that must appear in at least one payload for topic.

None
count int | None

Optional exact number of messages that must have been published to topic.

None

Raises:

Type Description
AssertionError

If no messages for topic, or if contains is not found in any payload, or if message count doesn't match count.

Source code in packages/src/cosalette/testing/_harness.py
def assert_published(
    self,
    topic: str,
    *,
    contains: str | None = None,
    count: int | None = None,
) -> None:
    """Assert that *topic* has published messages matching criteria.

    Args:
        topic: MQTT topic to check (exact match).
        contains: Optional substring that must appear in at least one
            payload for *topic*.
        count: Optional exact number of messages that must have been
            published to *topic*.

    Raises:
        AssertionError: If no messages for *topic*, or if *contains* is
            not found in any payload, or if message count doesn't match
            *count*.
    """
    messages = self.messages_for(topic)
    if not messages:
        raise AssertionError(f"No messages published to {topic!r}")
    if count is not None and len(messages) != count:
        raise AssertionError(
            f"Expected {count} message(s) to {topic!r}, got {len(messages)}"
        )
    if contains is not None and not any(
        contains in payload for payload, _, _ in messages
    ):
        raise AssertionError(f"No message on {topic!r} contains {contains!r}")

inject_command async

inject_command(
    device: str | None,
    payload: str | dict[str, Any],
    *,
    topic: str | None = None,
    unsafe: bool = False,
) -> None

Simulate an inbound MQTT command to device.

Delivers a message to {topic_prefix}/{device}/set (or {topic_prefix}/set for root commands) via the :class:MockMqttClient, triggering registered command callbacks.

This is an MQTT-delivery helper — the app must be running and callbacks must be registered for the command to be processed.

Parameters:

Name Type Description Default
device str | None

Device name as registered with @app.command, or None for root commands (matching @app.command(None) registration semantics). None constructs the topic as {prefix}/set; any non-empty string constructs {prefix}/{device}/set.

required
payload str | dict[str, Any]

MQTT payload — either a JSON string or a dict that will be serialized to JSON.

required
topic str | None

Optional explicit topic override. When None (default), the topic is constructed from device.

None
unsafe bool

Skip device-name validation to allow adversarial topics (wildcards, separators, control characters) that production registration would reject. Defaults to False.

False
See Also

:meth:call_command for direct command handler invocation without requiring the app to be running.

Source code in packages/src/cosalette/testing/_harness.py
async def inject_command(
    self,
    device: str | None,
    payload: str | dict[str, Any],
    *,
    topic: str | None = None,
    unsafe: bool = False,
) -> None:
    """Simulate an inbound MQTT command to *device*.

    Delivers a message to ``{topic_prefix}/{device}/set`` (or
    ``{topic_prefix}/set`` for root commands) via the
    :class:`MockMqttClient`, triggering registered command callbacks.

    This is an MQTT-delivery helper — the app must be running and
    callbacks must be registered for the command to be processed.

    Args:
        device: Device name as registered with ``@app.command``, or
            ``None`` for root commands (matching ``@app.command(None)``
            registration semantics). ``None`` constructs the topic as
            ``{prefix}/set``; any non-empty string constructs
            ``{prefix}/{device}/set``.
        payload: MQTT payload — either a JSON string or a dict that will
            be serialized to JSON.
        topic: Optional explicit topic override. When ``None`` (default),
            the topic is constructed from *device*.
        unsafe: Skip device-name validation to allow adversarial topics
            (wildcards, separators, control characters) that production
            registration would reject. Defaults to ``False``.

    See Also:
        :meth:`call_command` for direct command handler invocation without
            requiring the app to be running.
    """
    if topic is None:
        topic_prefix = self._topic_prefix
        if device:
            # Guard against tests that would silently exercise topics the
            # production registration path rejects (wildcards, control
            # chars, path separators). Adversarial tests can opt out with
            # ``unsafe=True``.
            if not unsafe:
                from cosalette._registration._validation import (
                    validate_mqtt_name,
                )

                validate_mqtt_name(device)
            topic = f"{topic_prefix}/{device}/set"
        else:
            topic = f"{topic_prefix}/set"
    payload_str = _json_dumps(payload) if isinstance(payload, dict) else payload
    await self.mqtt.deliver(topic, payload_str)

call_command async

call_command(
    name: str,
    payload: str | dict[str, object],
    *,
    topic: str | None = None,
) -> None

Directly invoke a registered @app.command handler.

Resolves the handler by name, injects dependencies, calls it with the deserialized payload, and publishes any returned state to harness.mqtt — mirroring production execution without requiring the app to be running.

Supports production request binding including typed Pydantic payloads (Annotated[Model, Payload()]), payload/topic/message parameters, DeviceContext, and simple DI providers available to CommandRunner. Does NOT run adapter lifecycle, state factory lifecycle, or reactors.

Parameters:

Name Type Description Default
name str

Command handler name as registered with @app.command. Supports router-prefixed names like "router/sub". For root commands registered with @app.command(None), pass the function name.

required
payload str | dict[str, object]

MQTT payload — either a JSON string or a dict that will be serialized to JSON.

required
topic str | None

Optional MQTT topic string. When None (default), constructs {prefix}/{name}/set or {prefix}/set for root commands.

None

Raises:

Type Description
ValueError

If no command handler with name is registered.

Exception

Any exception raised by the handler is propagated.

Note

For tests requiring adapter lifecycle, state factory lifecycle, or reactor dispatch, use :meth:inject_command with the app running. init= command callbacks are NOT run; handlers that cache init results will receive None for those dependencies. Reactor dispatch is disabled; if the handler triggers side-effects via reactors, use :meth:inject_command with the app running instead.

See Also

:meth:inject_command for MQTT-delivery simulation requiring the app to be running.

Source code in packages/src/cosalette/testing/_harness.py
async def call_command(
    self,
    name: str,
    payload: str | dict[str, object],
    *,
    topic: str | None = None,
) -> None:
    """Directly invoke a registered ``@app.command`` handler.

    Resolves the handler by *name*, injects dependencies, calls it with
    the deserialized *payload*, and publishes any returned state to
    ``harness.mqtt`` — mirroring production execution without requiring
    the app to be running.

    Supports production request binding including typed Pydantic payloads
    (``Annotated[Model, Payload()]``), ``payload``/``topic``/``message``
    parameters, ``DeviceContext``, and simple DI providers available to
    ``CommandRunner``. Does NOT run adapter lifecycle, state factory
    lifecycle, or reactors.

    Args:
        name: Command handler name as registered with ``@app.command``.
            Supports router-prefixed names like ``"router/sub"``. For
            root commands registered with ``@app.command(None)``, pass
            the function name.
        payload: MQTT payload — either a JSON string or a dict that will
            be serialized to JSON.
        topic: Optional MQTT topic string. When ``None`` (default),
            constructs ``{prefix}/{name}/set`` or ``{prefix}/set`` for
            root commands.

    Raises:
        ValueError: If no command handler with *name* is registered.
        Exception: Any exception raised by the handler is propagated.

    Note:
        For tests requiring adapter lifecycle, state factory lifecycle,
        or reactor dispatch, use :meth:`inject_command` with the app
        running. ``init=`` command callbacks are NOT run; handlers that
        cache ``init`` results will receive ``None`` for those
        dependencies. Reactor dispatch is disabled; if the handler
        triggers side-effects via reactors, use :meth:`inject_command`
        with the app running instead.

    See Also:
        :meth:`inject_command` for MQTT-delivery simulation requiring the
        app to be running.
    """
    topic_prefix = self._topic_prefix

    # Find the command registration
    try:
        reg = next(r for r in self.app._commands if r.name == name)
    except StopIteration:
        msg = f"No command handler named '{name}' found"
        raise ValueError(msg) from None

    # Construct topic if not provided
    if topic is None:
        if reg.is_root:
            topic = f"{topic_prefix}/set"
        else:
            topic = f"{topic_prefix}/{name}/set"

    # Serialize payload using the project's JSON backend (orjson) for
    # consistency with production encoding behaviour.
    payload_str = _json_dumps(payload) if isinstance(payload, dict) else payload

    # Build DeviceContext for command execution
    ctx = DeviceContext(
        name=name,
        settings=self.settings,
        mqtt=self.mqtt,
        topic_prefix=topic_prefix,
        shutdown_event=self.shutdown_event,
        adapters={},
        clock=self.clock,
        is_root=reg.is_root,
    )

    # Create CommandRunner and execute (reactors=None skips reactor dispatch)
    cmd_runner = CommandRunner(store=self.app._store)
    error_publisher = ErrorPublisher(
        mqtt=self.mqtt,
        topic_prefix=topic_prefix,
    )

    await cmd_runner.run_command(
        reg=reg,
        ctx=ctx,
        topic=topic,
        payload=payload_str,
        error_publisher=error_publisher,
        reactors=None,
    )

advance_time async

advance_time(seconds: float) -> None

Move the harness clock forward by seconds, releasing due sleeps.

Honest scheduler control under a gating :class:ManualClock: this delegates to :meth:ManualClock.advance, so virtual time steps deadline by deadline, every sleeper due within the span is released, and the event loop is driven to quiescence before returning. A runner parked on clock.sleep therefore wakes exactly when the name says it should.

Under a non-gating clock (the default :class:FakeClock) there are no waiters to release, so this delegates to clock.sleep — it advances virtual time by seconds and yields once to the event loop, the same behaviour these tests have always had.

Parameters:

Name Type Description Default
seconds float

Virtual seconds to advance.

required
See Also

ADR-071 for the two clock doubles and their contracts.

Source code in packages/src/cosalette/testing/_harness.py
async def advance_time(self, seconds: float) -> None:
    """Move the harness clock forward by *seconds*, releasing due sleeps.

    Honest scheduler control under a gating :class:`ManualClock`: this
    delegates to :meth:`ManualClock.advance`, so virtual time steps
    deadline by deadline, every sleeper due within the span is released,
    and the event loop is driven to quiescence before returning. A runner
    parked on ``clock.sleep`` therefore wakes exactly when the name says
    it should.

    Under a non-gating clock (the default :class:`FakeClock`) there are no
    waiters to release, so this delegates to ``clock.sleep`` — it advances
    virtual time by *seconds* and yields once to the event loop, the same
    behaviour these tests have always had.

    Args:
        seconds: Virtual seconds to advance.

    See Also:
        ADR-071 for the two clock doubles and their contracts.
    """
    if isinstance(self.clock, ManualClock):
        await self.clock.advance(seconds)
    else:
        await self.clock.sleep(seconds)

wait_for_publish_count async

wait_for_publish_count(
    topic: str, count: int, *, max_rounds: int = 10000
) -> None

Wait until topic has at least count published messages.

The supported replacement for the hand-rolled for _ in range(10_000): await asyncio.sleep(0) spin: it yields to the event loop until the awaited publish lands, then returns.

Under a gating :class:ManualClock it delegates to settle(until=...) — a real wait that never moves virtual time and raises if the count is never reached. Under a non-gating clock it yields up to max_rounds times, raising the same way on timeout.

Note

This never advances time. Under :class:ManualClock, a publish gated behind a scheduled sleep needs an explicit :meth:advance_time (or clock.advance) first; this call then waits for the released work to reach the wire.

Parameters:

Name Type Description Default
topic str

Exact MQTT topic to count publishes on.

required
count int

Target number of messages on topic (>=).

required
max_rounds int

Event-loop rounds to spend before giving up.

10000

Raises:

Type Description
ValueError

If count or max_rounds is not a positive integer.

RuntimeError

If count messages never land within max_rounds.

Source code in packages/src/cosalette/testing/_harness.py
async def wait_for_publish_count(
    self, topic: str, count: int, *, max_rounds: int = 10_000
) -> None:
    """Wait until *topic* has at least *count* published messages.

    The supported replacement for the hand-rolled
    ``for _ in range(10_000): await asyncio.sleep(0)`` spin: it yields to
    the event loop until the awaited publish lands, then returns.

    Under a gating :class:`ManualClock` it delegates to
    ``settle(until=...)`` — a real wait that never moves virtual time and
    raises if the count is never reached. Under a non-gating clock it
    yields up to *max_rounds* times, raising the same way on timeout.

    Note:
        This never advances time. Under :class:`ManualClock`, a publish
        gated behind a scheduled sleep needs an explicit
        :meth:`advance_time` (or ``clock.advance``) first; this call then
        waits for the released work to reach the wire.

    Args:
        topic: Exact MQTT topic to count publishes on.
        count: Target number of messages on *topic* (``>=``).
        max_rounds: Event-loop rounds to spend before giving up.

    Raises:
        ValueError: If *count* or *max_rounds* is not a positive integer.
        RuntimeError: If *count* messages never land within *max_rounds*.
    """
    if count < 1:
        msg = f"count must be a positive integer, got {count!r}"
        raise ValueError(msg)
    if max_rounds < 1:
        msg = f"max_rounds must be a positive integer, got {max_rounds!r}"
        raise ValueError(msg)

    def reached() -> bool:
        return len(self.messages_for(topic)) >= count

    if isinstance(self.clock, ManualClock):
        try:
            await self.clock.settle(until=reached, max_rounds=max_rounds)
        except RuntimeError as exc:
            raise self._publish_count_timeout(topic, count, max_rounds) from exc
        return
    for _ in range(max_rounds):
        if reached():
            return
        await asyncio.sleep(0)
    # Re-check after the final yield: a publish that lands during that last
    # ``asyncio.sleep(0)`` would otherwise time out spuriously.
    if reached():
        return
    raise self._publish_count_timeout(topic, count, max_rounds)

run_stream async

run_stream(
    func: Any,
    adapters: dict[type, Any],
    *,
    shutdown: Event | None = None,
) -> None

Run a stream handler's full lifecycle (open → scan → close).

Constructs a minimal :class:_StreamRegistration from func, then calls :func:run_stream with the provided adapters. Useful for testing stream handler behaviour without wiring a full app.

Parameters:

Name Type Description Default
func Any

The async-generator stream handler to run.

required
adapters dict[type, Any]

Resolved adapter map keyed by port type (e.g. {StreamablePort[Item]: my_port_instance}).

required
shutdown Event | None

Optional :class:asyncio.Event to trigger graceful shutdown. Defaults to :attr:shutdown_event.

None
Source code in packages/src/cosalette/testing/_harness.py
async def run_stream(
    self,
    func: Any,
    adapters: dict[type, Any],
    *,
    shutdown: asyncio.Event | None = None,
) -> None:
    """Run a stream handler's full lifecycle (open → scan → close).

    Constructs a minimal :class:`_StreamRegistration` from *func*, then
    calls :func:`run_stream` with the provided *adapters*.  Useful for
    testing stream handler behaviour without wiring a full app.

    Args:
        func: The async-generator stream handler to run.
        adapters: Resolved adapter map keyed by port type
            (e.g. ``{StreamablePort[Item]: my_port_instance}``).
        shutdown: Optional :class:`asyncio.Event` to trigger graceful
            shutdown.  Defaults to :attr:`shutdown_event`.
    """
    from cosalette._injection import build_injection_plan
    from cosalette._registration import _StreamRegistration
    from cosalette._runners._stream_runner import run_stream as _run_stream

    plan = build_injection_plan(func)
    reg = _StreamRegistration(
        name="test_stream",
        func=func,
        injection_plan=plan,
        enabled_spec=True,
        summary=None,
        behavior=None,
        effects=None,
    )
    await _run_stream(
        reg, adapters, {}, shutdown if shutdown is not None else self.shutdown_event
    )

Quick Examples

harness = AppHarness.create(name="myapp")
# ... run harness ...

# Assert a retained JSON message is a superset of expected
harness.assert_state("myapp/sensor/state", {"value": 42})

# Assert the app subscribed to a topic
harness.assert_subscribed("myapp/sensor/set")

# Inject a command with a dict payload (auto-serialized to JSON)
await harness.inject_command("sensor", {"threshold": 10})

Clocks

Two clock doubles ship, as siblings rather than as a subclass pair — their sleep() contracts are deliberately incompatible.

Double sleep() advance() Reach for it when
FakeClock Self-completes in one event-loop iteration, advancing virtual time Synchronous; moves time and yields to nothing The test only needs virtual elapsed time
ManualClock Blocks on a per-sleeper deadline until advance() releases it A coroutine; steps time deadline by deadline and drives the loop between steps The test asserts absence — that no scheduled tick fired — or an exact publish count

cosalette.testing.FakeClock dataclass

FakeClock(
    _time: float = 0.0,
    _wakes: WeakKeyDictionary[
        Task[Any], float
    ] = WeakKeyDictionary(),
    _seen: float = 0.0,
)

Bases: _BaseClock

Test double for ClockPort.

What it cannot measure: :meth:sleep advances virtual time with no real delay, so it completes in a single event-loop iteration and wins any race against a real asyncio.Event that another task has yet to set — whatever duration was requested. A test therefore cannot use it to prove that a scheduled tick did not fire, and cannot assert an exact publish count (that count reflects how many event-loop yields the test happened to burn). To tell a trigger-initiated run from a scheduled tick, check TriggerPayload.is_triggered. Reach for :class:ManualClock when the assertion is about a tick that must not fire. See ADR-071.

What it does measure: each task's own timeline. A sleep is charged to the task that awaited it, so a concurrent sleeper never lengthens another task's interval — a loop sleeping 3600 beside a reporter sleeping 240 wakes 3600 apart, not 3840. now() remains a single shared value, so a task that has run further ahead can still show a later one a time past its own deadline; only :class:ManualClock, which gates, keeps those apart in every interleaving.

Attributes:

Name Type Description
_time float

The current "now" value returned by now(). Assign it to set virtual time absolutely, or call :meth:advance to move it forward relatively. Either one restarts every task's timeline at the new value — the clock was moved out from under them, so their old deadlines no longer describe anything.

Example::

clock = FakeClock(42.0)
assert clock.now() == 42.0
clock.advance(57.0)
assert clock.now() == 99.0

now

now() -> float

Return the manually set time value.

Source code in packages/src/cosalette/testing/_clock.py
def now(self) -> float:
    """Return the manually set time value."""
    return self._time

advance

advance(seconds: float) -> None

Move virtual time forward by seconds, without sleeping.

Relative to the current value, unlike assigning _time, which sets virtual time absolutely. Unlike :meth:sleep this does not yield to the event loop, so no other task gets to run — use it to simulate work that consumed time.

Parameters:

Name Type Description Default
seconds float

Virtual seconds to add. 0 is a no-op.

required

Raises:

Type Description
ValueError

If seconds is negative. A monotonic clock never runs backwards.

Example::

clock = FakeClock(10.0)
clock.advance(5.0)
assert clock.now() == 15.0
Source code in packages/src/cosalette/testing/_clock.py
def advance(self, seconds: float) -> None:
    """Move virtual time forward by *seconds*, without sleeping.

    Relative to the current value, unlike assigning ``_time``,
    which sets virtual time absolutely.  Unlike :meth:`sleep` this
    does not yield to the event loop, so no other task gets to run
    — use it to simulate work that consumed time.

    Args:
        seconds: Virtual seconds to add.  ``0`` is a no-op.

    Raises:
        ValueError: If *seconds* is negative.  A monotonic clock
            never runs backwards.

    Example::

        clock = FakeClock(10.0)
        clock.advance(5.0)
        assert clock.now() == 15.0
    """
    self._reject_negative(seconds)
    self._time += seconds

sleep async

sleep(seconds: float) -> None

Advance virtual time by seconds with no real delay.

Allows tests to exercise sleep-dependent code paths without wall-clock waiting. The asyncio.sleep(0) yields to the event loop so concurrent tasks interleave correctly.

The duration is charged to the awaiting task's own deadline, not to a single shared accumulator: now() moves to the latest deadline any task has reached, so a concurrent sleeper never pushes this one's next wake further out.

The task's base is read before the yield, so a concurrent sleeper that moves now() while this call is parked cannot be mistaken for this task's own starting point — the per-task guarantee then holds whatever order the loop resumes the sleepers.

Source code in packages/src/cosalette/testing/_clock.py
async def sleep(self, seconds: float) -> None:
    """Advance virtual time by *seconds* with no real delay.

    Allows tests to exercise sleep-dependent code paths
    without wall-clock waiting.  The ``asyncio.sleep(0)``
    yields to the event loop so concurrent tasks interleave
    correctly.

    The duration is charged to the awaiting task's own deadline, not
    to a single shared accumulator: ``now()`` moves to the latest
    deadline any task has reached, so a concurrent sleeper never
    pushes this one's next wake further out.

    The task's base is read *before* the yield, so a concurrent
    sleeper that moves ``now()`` while this call is parked cannot be
    mistaken for this task's own starting point — the per-task
    guarantee then holds whatever order the loop resumes the sleepers.
    """
    if seconds <= 0:
        await asyncio.sleep(0)
        return
    task = asyncio.current_task()
    if task is None:  # pragma: no cover — sleep() is always awaited in a task
        await asyncio.sleep(0)
        self._time += seconds
        return
    base = self._wakes.get(task, self._time)
    await asyncio.sleep(0)
    self._time = max(self._time, self._charge(task, base, seconds))
    self._seen = self._time

What FakeClock cannot measure

FakeClock.sleep() advances virtual time with no real delay, so it completes in a single event-loop iteration and wins any race against a real asyncio.Event that another task has yet to set — regardless of the duration requested. A test therefore cannot use it to prove that a scheduled tick did not fire, and cannot assert an exact publish count (that count reflects how many event-loop yields the test happened to burn). To discriminate a trigger-initiated run from a scheduled tick, check TriggerPayload.is_triggered. See ADR-071.

Use clock.advance(seconds) to move virtual time forward relatively without yielding to the event loop; assigning clock._time sets it absolutely. When the assertion is about a tick that must not fire, use ManualClock instead.

cosalette.testing.ManualClock dataclass

ManualClock(
    _time: float = 0.0,
    _waiters: list[_Waiter] = list(),
    _ops: int = 0,
    _advancing: bool = False,
)

Bases: _BaseClock

Gating test double for ClockPort — nothing but you moves time.

:meth:sleep registers a deadline at now() + seconds and blocks on an asyncio.Event that only :meth:advance sets. A scheduled tick therefore cannot fire unless the test asks for it, which is what makes "no tick happened" an assertable outcome rather than a guess — the thing :class:FakeClock cannot express. Deadlines are per sleeper, so concurrent tasks do not contribute to each other's timelines.

Use :class:FakeClock when a test only needs virtual elapsed time; reach for this one when the assertion is about absence or about an exact count. See ADR-071.

Prefer asserting state after :meth:advance (or after settle(until=...)) over asserting absence after a bare :meth:settle: the bare form is a bounded heuristic and can report a still-working task as quiescent. :meth:settle documents exactly when.

Attributes:

Name Type Description
_time float

The current "now" value returned by now().

Example::

clock = ManualClock()
fired: list[float] = []

async def tick() -> None:
    await clock.sleep(3600)
    fired.append(clock.now())

task = asyncio.create_task(tick())
await clock.settle()
assert fired == []  # nothing releases the sleep but advance()

await clock.advance(3600)
await clock.settle(until=lambda: bool(fired))
assert fired == [3600.0]
await task

now

now() -> float

Return the manually set time value.

Source code in packages/src/cosalette/testing/_clock.py
def now(self) -> float:
    """Return the manually set time value."""
    return self._time

sleep async

sleep(seconds: float) -> None

Block until :meth:advance moves time to now() + seconds.

Nothing else completes a positive sleep: no wall-clock time passes, and no number of event-loop iterations releases it. The deadline is captured per call, so a concurrent sleeper's duration never leaks into this one's timeline.

A non-positive seconds is the deliberate carve-out to that guarantee: it is already elapsed by definition, so it yields to the event loop once and returns, exactly like asyncio.sleep(0). The framework's own sleep(max(0.0, deadline - now())) throttle arithmetic depends on this — a gating sleep(0) would deadlock the runners this clock exists to test. The cost is real: a consumer whose deadline has already gone stale computes 0.0 every cycle and free-runs without any :meth:advance, so this clock does not gate it at all. Such a loop churns the observed operation counter, so :meth:settle catches it and raises rather than returning a false "quiescent" — but only after the loop has run some cycles, and the work those cycles did has really happened.

Parameters:

Name Type Description Default
seconds float

Virtual seconds to wait. <= 0 yields only.

required

Raises:

Type Description
ValueError

If seconds is NaN or infinite — such a deadline would never be reached, gating the sleeper forever.

Source code in packages/src/cosalette/testing/_clock.py
async def sleep(self, seconds: float) -> None:
    """Block until :meth:`advance` moves time to ``now() + seconds``.

    Nothing else completes a *positive* sleep: no wall-clock time
    passes, and no number of event-loop iterations releases it.  The
    deadline is captured per call, so a concurrent sleeper's duration
    never leaks into this one's timeline.

    A non-positive *seconds* is the deliberate carve-out to that
    guarantee: it is already elapsed by definition, so it yields to
    the event loop once and returns, exactly like ``asyncio.sleep(0)``.
    The framework's own ``sleep(max(0.0, deadline - now()))`` throttle
    arithmetic depends on this — a gating ``sleep(0)`` would deadlock
    the runners this clock exists to test.  The cost is real: a
    consumer whose deadline has already gone stale computes ``0.0``
    every cycle and free-runs without any :meth:`advance`, so this
    clock does not gate it at all.  Such a loop churns the observed
    operation counter, so :meth:`settle` catches it and raises rather
    than returning a false "quiescent" — but only after the loop has
    run some cycles, and the work those cycles did has really
    happened.

    Args:
        seconds: Virtual seconds to wait.  ``<= 0`` yields only.

    Raises:
        ValueError: If *seconds* is NaN or infinite — such a deadline
            would never be reached, gating the sleeper forever.
    """
    self._reject_non_finite(seconds, caller="sleep")
    self._ops += 1
    if seconds <= 0:
        await asyncio.sleep(0)
        return
    waiter = _Waiter(self._time + seconds, asyncio.Event())
    self._waiters.append(waiter)
    try:
        await waiter.event.wait()
    finally:
        self._ops += 1
        # A cancel before release leaves the waiter registered; a
        # release has already dropped it, so removal is best-effort.
        with contextlib.suppress(ValueError):
            self._waiters.remove(waiter)

advance async

advance(
    seconds: float,
    *,
    max_wakes: int = _ADVANCE_WAKES,
    stable_rounds: int = _STABLE_ROUNDS,
) -> None

Move virtual time forward by seconds, releasing sleepers.

Waiters are released in deadline order, and each observes now() at its own deadline rather than at the final target: under advance(10) a sleeper due at t+1 reads t+1. Time therefore steps deadline by deadline and lands on the target last. Sleeps registered by the tasks this wakes are honoured too, as long as they fall at or before the target.

Quiescence contract: after each release the event loop is driven to quiescence via :meth:settle before time moves again, and once more after time reaches the target. So on return every task this advance woke has run as far as it can — up to the same heuristic limit :meth:settle documents, which stable_rounds tunes. This method is a coroutine for that reason, unlike :meth:FakeClock.advance.

Exactly one advance may be in flight: the target is captured on entry and written on exit, so a nested or concurrent call would run virtual time backwards when the inner one returned. A second entry raises instead of doing that silently.

Parameters:

Name Type Description Default
seconds float

Virtual seconds to add. 0 releases nothing new but still settles the loop.

required
max_wakes int

Deadline batches to release before giving up. Guards against a task that sleeps in a tight loop across a very large seconds.

_ADVANCE_WAKES
stable_rounds int

Forwarded to every :meth:settle this makes. Raise it for a task that takes several plain await hops between waking and its observable effect.

_STABLE_ROUNDS

Raises:

Type Description
ValueError

If seconds is negative or non-finite (a monotonic clock never runs backwards), or if max_wakes or stable_rounds is not a positive integer.

RuntimeError

If another advance() is already in flight, if max_wakes batches are released without reaching the target, or if :meth:settle gives up. In the latter two cases the clock is left partially advanced — time has moved to the last deadline released, not to the target — so the instance should not be reused after the failure.

Source code in packages/src/cosalette/testing/_clock.py
async def advance(
    self,
    seconds: float,
    *,
    max_wakes: int = _ADVANCE_WAKES,
    stable_rounds: int = _STABLE_ROUNDS,
) -> None:
    """Move virtual time forward by *seconds*, releasing sleepers.

    Waiters are released in deadline order, and each observes
    ``now()`` at *its own* deadline rather than at the final target:
    under ``advance(10)`` a sleeper due at ``t+1`` reads ``t+1``.
    Time therefore steps deadline by deadline and lands on the target
    last.  Sleeps registered by the tasks this wakes are honoured too,
    as long as they fall at or before the target.

    Quiescence contract: after each release the event loop is driven
    to quiescence via :meth:`settle` before time moves again, and once
    more after time reaches the target.  So on return every task this
    advance woke has run as far as it can — up to the same heuristic
    limit :meth:`settle` documents, which *stable_rounds* tunes.  This
    method is a coroutine for that reason, unlike
    :meth:`FakeClock.advance`.

    Exactly one advance may be in flight: the target is captured on
    entry and written on exit, so a nested or concurrent call would
    run virtual time backwards when the inner one returned.  A second
    entry raises instead of doing that silently.

    Args:
        seconds: Virtual seconds to add.  ``0`` releases nothing new
            but still settles the loop.
        max_wakes: Deadline batches to release before giving up.
            Guards against a task that sleeps in a tight loop across
            a very large *seconds*.
        stable_rounds: Forwarded to every :meth:`settle` this makes.
            Raise it for a task that takes several plain ``await``
            hops between waking and its observable effect.

    Raises:
        ValueError: If *seconds* is negative or non-finite (a monotonic
            clock never runs backwards), or if *max_wakes* or
            *stable_rounds* is not a positive integer.
        RuntimeError: If another ``advance()`` is already in flight,
            if *max_wakes* batches are released without reaching the
            target, or if :meth:`settle` gives up.  In the latter two
            cases the clock is left *partially advanced* — time has
            moved to the last deadline released, not to the target —
            so the instance should not be reused after the failure.
    """
    self._reject_negative(seconds)
    self._reject_non_positive("max_wakes", max_wakes)
    self._reject_non_positive("stable_rounds", stable_rounds)
    self._reject_reentry()
    self._advancing = True
    try:
        target = self._time + seconds
        await self._release_batches(target, max_wakes, stable_rounds)
        self._time = target
        await self.settle(stable_rounds=stable_rounds)
    finally:
        self._advancing = False

settle async

settle(
    *,
    until: Callable[[], bool] | None = None,
    max_rounds: int = _SETTLE_ROUNDS,
    stable_rounds: int = _STABLE_ROUNDS,
) -> None

Drive the event loop forward without moving time.

Two modes, and the difference matters:

  • settle() — a bounded heuristic. It returns when the loop looks idle, which is not the same as proving no further work is pending.
  • settle(until=predicate) — a real wait. It returns only once predicate holds, and raises if it never does. Use this whenever a test depends on some effect having landed.

Quiescence contract: each round yields once to the event loop and compares an observation of (the set of pending tasks, the pending sleep deadlines on this clock, and a counter of sleep registrations and releases). Quiescence is declared only after stable_rounds consecutive rounds change none of them, because a single quiet round is not enough: an asyncio.wait callback chain — the shape all three of the framework's own runner sleep sites use — passes through a round in which none of the three observable quantities moves. Virtual time never moves: only :meth:advance moves it, in either mode.

The observation is a heuristic, because asyncio exposes no supported idle hook and this deliberately does not read the loop's private ready queue (ADR-071). It fails in both directions, and both are bounded:

  • Under-settling, silently. A task that only awaits plain asyncio.sleep(0) hops between being woken and producing its effect touches none of the three quantities, so it is invisible here. A task taking more such hops than stable_rounds is reported quiescent while it is still working, and its effect lands after this returns. Prefer asserting the state you expect after :meth:advance or settle(until=...) over asserting the absence of an effect after a bare settle(); raise stable_rounds if a specific task needs more room.
  • Never settling, loudly. A task that churns any of the three observed quantities forever hits max_rounds and raises.

Parameters:

Name Type Description Default
until Callable[[], bool] | None

Optional predicate. When given, rounds are spent until it returns true (plus one further yield so the task that satisfied it can take its next step); stable_rounds is not consulted.

None
max_rounds int

Event-loop rounds to spend before giving up.

_SETTLE_ROUNDS
stable_rounds int

Consecutive unchanged rounds that count as quiescence when until is not given.

_STABLE_ROUNDS

Raises:

Type Description
ValueError

If max_rounds or stable_rounds is not a positive integer.

RuntimeError

If the loop is still churning after max_rounds, or if until never became true within it.

Source code in packages/src/cosalette/testing/_clock.py
async def settle(
    self,
    *,
    until: Callable[[], bool] | None = None,
    max_rounds: int = _SETTLE_ROUNDS,
    stable_rounds: int = _STABLE_ROUNDS,
) -> None:
    """Drive the event loop forward *without* moving time.

    Two modes, and the difference matters:

    - ``settle()`` — a **bounded heuristic**.  It returns when the
      loop *looks* idle, which is not the same as proving no further
      work is pending.
    - ``settle(until=predicate)`` — a **real wait**.  It returns only
      once *predicate* holds, and raises if it never does.  Use this
      whenever a test depends on some effect having landed.

    Quiescence contract: each round yields once to the event loop and
    compares an observation of (the set of pending tasks, the pending
    sleep deadlines on this clock, and a counter of sleep
    registrations and releases).  Quiescence is declared only after
    *stable_rounds* **consecutive** rounds change none of them,
    because a single quiet round is not enough: an ``asyncio.wait``
    callback chain — the shape all three of the framework's own
    runner sleep sites use — passes through a round in which none of
    the three observable quantities moves.  Virtual time never moves:
    only :meth:`advance` moves it, in either mode.

    The observation is a heuristic, because asyncio exposes no
    supported idle hook and this deliberately does not read the
    loop's private ready queue (ADR-071).  It fails in both
    directions, and both are bounded:

    - **Under-settling, silently.**  A task that only awaits plain
      ``asyncio.sleep(0)`` hops between being woken and producing its
      effect touches none of the three quantities, so it is invisible
      here.  A task taking more such hops than *stable_rounds* is
      reported quiescent while it is still working, and its effect
      lands after this returns.  Prefer asserting the state you
      expect after :meth:`advance` or ``settle(until=...)`` over
      asserting the absence of an effect after a bare ``settle()``;
      raise *stable_rounds* if a specific task needs more room.
    - **Never settling, loudly.**  A task that churns any of the
      three observed quantities forever hits *max_rounds* and raises.

    Args:
        until: Optional predicate.  When given, rounds are spent
            until it returns true (plus one further yield so the task
            that satisfied it can take its next step); *stable_rounds*
            is not consulted.
        max_rounds: Event-loop rounds to spend before giving up.
        stable_rounds: Consecutive unchanged rounds that count as
            quiescence when *until* is not given.

    Raises:
        ValueError: If *max_rounds* or *stable_rounds* is not a
            positive integer.
        RuntimeError: If the loop is still churning after
            *max_rounds*, or if *until* never became true within it.
    """
    self._reject_non_positive("max_rounds", max_rounds)
    self._reject_non_positive("stable_rounds", stable_rounds)
    if until is not None:
        await self._settle_until(until, max_rounds)
        return
    stable = 0
    prev = self._observe()
    for _ in range(max_rounds):
        await asyncio.sleep(0)
        curr = self._observe()
        stable = stable + 1 if curr == prev else 0
        prev = curr
        if stable >= stable_rounds:
            return
    msg = (
        f"ManualClock.settle() gave up after {max_rounds} event-loop rounds: "
        "the loop is still scheduling work at the current virtual time. A task "
        "is most likely spinning or creating tasks faster than they finish. "
        "Fix the task under test, or raise max_rounds=."
    )
    raise RuntimeError(msg)

Quiescence contract

ManualClock.settle() and ManualClock.advance() share one heuristic, and it is the part of the double worth understanding before relying on it.

settle() yields to the event loop one round at a time and, after each round, compares three observations: the set of pending asyncio tasks, the pending sleep deadlines registered on the clock, and a counter of sleep registrations and releases. Quiescence is declared only after three consecutive rounds change none of them — one quiet round is not enough, because the asyncio.wait race the framework's own shutdown-aware sleep uses passes through a round where none of the three quantities moves. Tune the count with settle(stable_rounds=...), which advance() forwards. settle() then returns without moving virtual time. Only advance() moves time.

settle(until=predicate) is the other mode, and the difference matters:

  • settle() alone is a bounded heuristic. It returns when the loop looks idle, which is not a proof that no work is still pending.
  • settle(until=predicate) is a real wait. It spends rounds until predicate holds — then one more — and raises RuntimeError if it never does.

advance(seconds) steps virtual time deadline by deadline rather than jumping straight to the target, so a sleeper due at t+1 inside advance(10) reads now() == t+1. After each release it calls settle() before moving time again, and once more when time reaches the target — so on return, every task the advance woke has run as far as the heuristic can tell. Exactly one advance() may be in flight: a nested or concurrent call raises rather than silently rewinding time to the outer call's target.

The heuristic has two edges

asyncio exposes no supported loop-idle hook, and this deliberately does not read the loop's private ready queue (ADR-071). So:

  • It can under-settle, silently. A task that only takes plain await hops between being woken and producing its effect touches none of the three observed quantities, so settle() can report it quiescent while it is still working and the effect lands afterwards. Prefer asserting the state you expect after advance() or settle(until=...) over asserting the absence of an effect after a bare settle(); raise stable_rounds= when one specific task needs more room. The same applies to a task that spins on asyncio.sleep(0) without touching the clock.
  • It can refuse to settle, loudly. A task that churns any of the three observed quantities forever is caught by the retry bound and raises RuntimeError rather than returning as if all were well. Raise the bound with settle(max_rounds=...) or advance(..., max_wakes=...) when a test legitimately needs more rounds.

One case falls through both edges: sleep() with a non-positive duration does not gate (see its docstring), so a consumer computing sleep(max(0.0, deadline - now())) against a deadline that has already passed free-runs without any advance() at all. settle() raises on such a loop, but only after it has run some cycles.

from cosalette.testing import ManualClock

clock = ManualClock()
fired: list[float] = []


async def tick() -> None:
    await clock.sleep(3600)
    fired.append(clock.now())


task = asyncio.create_task(tick())

await clock.settle()
assert fired == []          # nothing but advance() releases the sleep

await clock.advance(3600)
await clock.settle(until=lambda: bool(fired))   # a real wait, not a guess
assert fired == [3600.0]
await task

MQTT Test Doubles

cosalette.testing.MockMqttClient dataclass

MockMqttClient(
    published: list[tuple[str, str, bool, int]] = list(),
    subscriptions: list[str] = list(),
    raise_on_publish: Exception | None = None,
)

In-memory test double that records MQTT interactions.

Records publishes and subscriptions for assertion. Supports callback registration and simulated message delivery via deliver().

publish_count property

publish_count: int

Number of recorded publishes.

subscribe_count property

subscribe_count: int

Number of recorded subscriptions.

publish async

publish(
    topic: str,
    payload: str | dict[str, Any],
    *,
    retain: bool = False,
    qos: int = 1,
) -> None

Record a publish call, or raise if raise_on_publish is set.

Source code in packages/src/cosalette/_mqtt/__init__.py
async def publish(
    self,
    topic: str,
    payload: str | dict[str, Any],
    *,
    retain: bool = False,
    qos: int = 1,
) -> None:
    """Record a publish call, or raise if ``raise_on_publish`` is set."""
    if self.raise_on_publish is not None:
        raise self.raise_on_publish
    if isinstance(payload, dict):
        from cosalette._json import dumps

        payload = dumps(payload)
    self.published.append((topic, payload, retain, qos))

subscribe async

subscribe(topic: str) -> None

Record a subscribe call.

Source code in packages/src/cosalette/_mqtt/__init__.py
async def subscribe(self, topic: str) -> None:
    """Record a subscribe call."""
    self.subscriptions.append(topic)

on_message

on_message(callback: MessageCallback) -> None

Register an inbound-message callback.

Source code in packages/src/cosalette/_mqtt/__init__.py
def on_message(self, callback: MessageCallback) -> None:
    """Register an inbound-message callback."""
    self._callbacks.append(callback)

deliver async

deliver(topic: str, payload: str) -> None

Simulate an inbound message by invoking all callbacks.

Source code in packages/src/cosalette/_mqtt/__init__.py
async def deliver(self, topic: str, payload: str) -> None:
    """Simulate an inbound message by invoking all callbacks."""
    for cb in self._callbacks:
        await cb(topic, payload)

reset

reset() -> None

Clear all recorded data, callbacks, and failure injection.

Source code in packages/src/cosalette/_mqtt/__init__.py
def reset(self) -> None:
    """Clear all recorded data, callbacks, and failure injection."""
    self.published.clear()
    self.subscriptions.clear()
    self._callbacks.clear()
    self.raise_on_publish = None

get_messages_for

get_messages_for(topic: str) -> list[tuple[str, bool, int]]

Return (payload, retain, qos) tuples for topic.

Source code in packages/src/cosalette/_mqtt/__init__.py
def get_messages_for(
    self,
    topic: str,
) -> list[tuple[str, bool, int]]:
    """Return ``(payload, retain, qos)`` tuples for *topic*."""
    return [
        (payload, retain, qos)
        for t, payload, retain, qos in self.published
        if t == topic
    ]

cosalette.testing.NullMqttClient dataclass

NullMqttClient()

Silent no-op MQTT adapter.

Every method is a no-op that logs at DEBUG level. Useful as a default when MQTT is not configured.

publish async

publish(
    topic: str,
    payload: str | dict[str, Any],
    *,
    retain: bool = False,
    qos: int = 1,
) -> None

Silently discard a publish request.

Source code in packages/src/cosalette/_mqtt/__init__.py
async def publish(
    self,
    topic: str,
    payload: str | dict[str, Any],  # noqa: ARG002
    *,
    retain: bool = False,  # noqa: ARG002
    qos: int = 1,  # noqa: ARG002
) -> None:
    """Silently discard a publish request."""
    logger.debug("NullMqttClient.publish(%s) — discarded", topic)

subscribe async

subscribe(topic: str) -> None

Silently discard a subscribe request.

Source code in packages/src/cosalette/_mqtt/__init__.py
async def subscribe(self, topic: str) -> None:
    """Silently discard a subscribe request."""
    logger.debug("NullMqttClient.subscribe(%s) — discarded", topic)

Settings Factory

cosalette.testing.make_settings

make_settings(**overrides: Any) -> Settings

Create a Settings instance with sensible test defaults.

Instantiates an :class:_IsolatedSettings subclass whose only configuration source is init_settings. This means the factory ignores os.environ, .env files, and secret directories — tests see only model defaults plus any explicit overrides.

Parameters:

Name Type Description Default
**overrides Any

Keyword arguments forwarded to the Settings constructor. Any field not provided falls back to the model defaults (e.g. mqtt.host="localhost").

{}

Returns:

Type Description
Settings

A fully initialised :class:Settings ready for test use.

Raises:

Type Description
TypeError

If an override names neither a Settings field nor the _config_file runtime kwarg. Settings itself uses extra="ignore" (it reads the whole unprefixed environment), so a typo'd or unsupported keyword would otherwise be swallowed silently — a test running against defaults it never asked for.

Example::

settings = make_settings()
assert settings.mqtt.host == "localhost"

from cosalette._settings import MqttSettings
custom = make_settings(mqtt=MqttSettings(host="broker.test"))
assert custom.mqtt.host == "broker.test"
Source code in packages/src/cosalette/testing/_settings.py
def make_settings(**overrides: Any) -> Settings:
    """Create a ``Settings`` instance with sensible test defaults.

    Instantiates an :class:`_IsolatedSettings` subclass whose only
    configuration source is ``init_settings``.  This means the
    factory ignores ``os.environ``, ``.env`` files, and secret
    directories — tests see only model defaults plus any explicit
    *overrides*.

    Parameters:
        **overrides: Keyword arguments forwarded to the ``Settings``
            constructor.  Any field not provided falls back to the
            model defaults (e.g. ``mqtt.host="localhost"``).

    Returns:
        A fully initialised :class:`Settings` ready for test use.

    Raises:
        TypeError: If an override names neither a ``Settings`` field nor
            the ``_config_file`` runtime kwarg.  ``Settings`` itself uses
            ``extra="ignore"`` (it reads the whole unprefixed environment),
            so a typo'd or unsupported keyword would otherwise be swallowed
            silently — a test running against defaults it never asked for.

    Example::

        settings = make_settings()
        assert settings.mqtt.host == "localhost"

        from cosalette._settings import MqttSettings
        custom = make_settings(mqtt=MqttSettings(host="broker.test"))
        assert custom.mqtt.host == "broker.test"
    """
    allowed_keys = _allowed_override_keys()
    unknown = overrides.keys() - allowed_keys
    if unknown:
        allowed = ", ".join(sorted(allowed_keys))
        offending = ", ".join(sorted(unknown))
        msg = (
            f"make_settings() got unexpected keyword argument(s): {offending}. "
            f"Valid settings overrides are: {allowed}."
        )
        raise TypeError(msg)
    # _env_file is a valid pydantic-settings runtime kwarg that disables
    # dotenv loading.
    return _IsolatedSettings(_env_file=None, **overrides)

Pytest Fixtures

The cosalette.testing package registers a pytest plugin via the pytest11 entry point. The fixtures below are available automatically when cosalette is installed — no conftest.py changes are needed.

Manual registration

If entry-point plugin discovery is disabled (e.g. -p no:cosalette), you can load the plugin explicitly:

tests/conftest.py
pytest_plugins = ["cosalette.testing._plugin"]

The fixtures below are available automatically once the plugin is registered:

Fixture Type Scope Description
mock_mqtt MockMqttClient function In-memory MQTT client for capturing published messages
fake_clock FakeClock function Deterministic clock starting at 0.0
device_context DeviceContext function Pre-wired context with mock_mqtt and fake_clock; name="test_device", topic_prefix="test"

All fixtures are function-scoped — each test receives a fresh instance.

Stream Handler Proxy

cosalette.testing.StreamHandlerProxy

StreamHandlerProxy(adapter: object)

Capability-limited proxy for stream adapter injection.

Wraps the concrete StreamablePort adapter and forwards all attribute access to the underlying adapter EXCEPT the four lifecycle methods (open, close, start_scan, stop_scan) which are reserved exclusively for the framework's :func:run_stream lifecycle management.

Handlers that inject a concrete adapter type receive this proxy rather than the raw adapter, preventing accidental lifecycle disruption.

See Also

ADR-045 — Stateful stream receiver semantics.

Source code in packages/src/cosalette/_runners/_stream_runner.py
def __init__(self, adapter: object) -> None:
    object.__setattr__(self, "_adapter", adapter)

MemoryStore

cosalette.MemoryStore

MemoryStore(
    initial: dict[str, dict[str, object]] | None = None,
)

In-memory store backed by a plain dict.

Both load and save deep-copy data so that callers cannot mutate internal state by accident. Designed for tests — mirrors the FakeStorage pattern from gas2mqtt.

Parameters

initial: Optional seed data. The mapping is deep-copied on construction.

Source code in packages/src/cosalette/_persistence/_stores.py
def __init__(
    self,
    initial: dict[str, dict[str, object]] | None = None,
) -> None:
    self._data: dict[str, dict[str, object]] = (
        copy.deepcopy(initial) if initial else {}
    )

load

load(key: str) -> dict[str, object] | None

Return a deep copy of the stored dict, or None.

Source code in packages/src/cosalette/_persistence/_stores.py
def load(self, key: str) -> dict[str, object] | None:
    """Return a deep copy of the stored dict, or ``None``."""
    value = self._data.get(key)
    if value is None:
        return None
    return copy.deepcopy(value)

save

save(key: str, data: dict[str, object]) -> None

Store a deep copy of data.

Source code in packages/src/cosalette/_persistence/_stores.py
def save(self, key: str, data: dict[str, object]) -> None:
    """Store a deep copy of *data*."""
    self._data[key] = copy.deepcopy(data)

MemoryStore is the recommended test double for persistence. It stores data in an in-memory dictionary, avoiding filesystem access in tests.

Default store in tests

Since ADR-049, omitting store= from App(...) auto-resolves a JsonFileStore at an XDG-derived path. In tests, always pass an explicit store to keep tests hermetic:

  • store=MemoryStore() — hermetic in-memory persistence; inspect with backend.load(key).
  • store=None — disable persistence entirely (no retained-topic cleanup).

The test suite sandboxes XDG_STATE_HOME to a temp dir so the default resolution does not touch the developer's home directory.

from cosalette import MemoryStore
from cosalette.testing import AppHarness

# Hermetic persistence — use MemoryStore()
backend = MemoryStore()
harness = AppHarness.create(store=backend)

# Pre-seed data
backend.save("sensor", {"count": 99})

# After test, inspect stored data
assert backend.load("sensor") == {"count": 99}

# No persistence at all — pass store=None
harness_no_store = AppHarness.create(store=None)

Test Seams

The _run_async() method accepts four optional injection parameters. When a parameter is None, the real implementation is used; when provided, the double replaces it for that run. AppHarness.create() assembles all four automatically.

Parameter Type Default (when None) Purpose
settings Settings Settings() from env/dotenv Skip environment variable loading
shutdown_event asyncio.Event Internal Event Programmatic shutdown signal
mqtt MqttClientPort Real MQTT client In-memory recording or no-op MQTT
clock ClockPort time.monotonic-based clock Deterministic time for uptime/strategy

See Direct Injection in the guide for a usage example.