Skip to content

Testing Utilities

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

Test Harness

cosalette.testing.AppHarness dataclass

AppHarness(
    app: App,
    mqtt: MockMqttClient,
    clock: FakeClock,
    settings: Settings,
    shutdown_event: Event,
)

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,
    **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
**settings_overrides Any

Forwarded to :func:make_settings.

{}

Returns:

Type Description
Self

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

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,
    **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.
        **settings_overrides: Forwarded to :func:`make_settings`.

    Returns:
        A fully wired :class:`AppHarness` ready for test use.
    """
    return cls(
        app=App(
            name=name,
            version=version,
            dry_run=dry_run,
            lifespan=lifespan,
            store=store,
        ),
        mqtt=MockMqttClient(),
        clock=FakeClock(),
        settings=make_settings(**settings_overrides),
        shutdown_event=asyncio.Event(),
    )

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."""
    await self.app._run_async(
        settings=self.settings,
        shutdown_event=self.shutdown_event,
        mqtt=self.mqtt,
        clock=self.clock,
    )

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()

Clock

cosalette.testing.FakeClock dataclass

FakeClock(_time: float = 0.0)

Test double for ClockPort.

Attributes:

Name Type Description
_time float

The current "now" value returned by now(). Set directly or via the constructor to control time in tests.

Example::

clock = FakeClock(42.0)
assert clock.now() == 42.0
clock._time = 99.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

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.

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.
    """
    await asyncio.sleep(0)
    if seconds > 0:
        self._time += seconds

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,
    *,
    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.py
async def publish(
    self,
    topic: str,
    payload: str,
    *,
    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
    self.published.append((topic, payload, retain, qos))

subscribe async

subscribe(topic: str) -> None

Record a subscribe call.

Source code in packages/src/cosalette/_mqtt.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.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.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.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.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,
    *,
    retain: bool = False,
    qos: int = 1,
) -> None

Silently discard a publish request.

Source code in packages/src/cosalette/_mqtt.py
async def publish(
    self,
    topic: str,
    payload: str,  # 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.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.

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.

    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"
    """
    # _env_file is a valid pydantic-settings runtime kwarg that disables
    # dotenv loading, but it isn't reflected in the generated __init__
    # signature — hence the type: ignore.
    return _IsolatedSettings(_env_file=None, **overrides)  # type: ignore[call-arg]

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:

Fixture Type Description
mock_mqtt MockMqttClient In-memory MQTT client for capturing published messages
fake_clock FakeClock Deterministic clock starting at 0.0
device_context DeviceContext Pre-wired context with mock_mqtt and fake_clock

All fixtures are function-scoped. Import them by name — no explicit import needed.

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/_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/_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/_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.

from cosalette import MemoryStore
from cosalette.testing import AppHarness

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}