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: |
None
|
store
|
Store | None
|
Optional :class: |
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 → |
None
|
disclose_messages_for
|
frozenset[type[Exception]] | None
|
Optional app-level message-disclosure set
forwarded to :class: |
None
|
retained_cleanup_snapshot_key
|
SecretStr | None
|
None
|
|
clock
|
ClockPort | None
|
Optional :class: |
None
|
**settings_overrides
|
Any
|
Forwarded to :func: |
{}
|
Returns:
| Type | Description |
|---|---|
Self
|
A fully wired :class: |
Raises:
| Type | Description |
|---|---|
TypeError
|
If a |
Source code in packages/src/cosalette/testing/_harness.py
run
async
¶
Run _run_async with the harness's test doubles.
Source code in packages/src/cosalette/testing/_harness.py
trigger_shutdown
¶
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: |
None
|
store
|
Store | None
|
Optional :class: |
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: |
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: |
Source code in packages/src/cosalette/testing/_harness.py
312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 | |
override_state
¶
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
tick_periodic
async
¶
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 |
required |
Raises:
| Type | Description |
|---|---|
ValueError
|
if no periodic task with name exists. |
Source code in packages/src/cosalette/testing/_harness.py
published
¶
Return a snapshot of all MQTT messages published so far.
Returns:
| Type | Description |
|---|---|
list[tuple[str, str, bool, int]]
|
Snapshot list of |
list[tuple[str, str, bool, int]]
|
is a copy — mutating the returned list does not affect the |
list[tuple[str, str, bool, int]]
|
class: |
Source code in packages/src/cosalette/testing/_harness.py
messages_for
¶
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 |
Source code in packages/src/cosalette/testing/_harness.py
last_published
¶
Return the most recent MQTT publish, or None if no publishes.
Returns:
| Type | Description |
|---|---|
tuple[str, str, bool, int] | None
|
|
Source code in packages/src/cosalette/testing/_harness.py
assert_state
¶
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
assert_subscribed
¶
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 |
Source code in packages/src/cosalette/testing/_harness.py
assert_published
¶
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
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 |
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
|
unsafe
|
bool
|
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.
Source code in packages/src/cosalette/testing/_harness.py
call_command
async
¶
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 |
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
|
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
658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 | |
advance_time
async
¶
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
wait_for_publish_count
async
¶
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
run_stream
async
¶
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. |
required |
shutdown
|
Event | None
|
Optional :class: |
None
|
Source code in packages/src/cosalette/testing/_harness.py
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 |
Example::
clock = FakeClock(42.0)
assert clock.now() == 42.0
clock.advance(57.0)
assert clock.now() == 99.0
now
¶
advance
¶
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. |
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
sleep
async
¶
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
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 |
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
¶
sleep
async
¶
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. |
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
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. |
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: |
_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 |
Source code in packages/src/cosalette/testing/_clock.py
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:advanceorsettle(until=...)over asserting the absence of an effect after a baresettle(); 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
381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 | |
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 raisesRuntimeErrorif 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
awaithops between being woken and producing its effect touches none of the three observed quantities, sosettle()can report it quiescent while it is still working and the effect lands afterwards. Prefer asserting the state you expect afteradvance()orsettle(until=...)over asserting the absence of an effect after a baresettle(); raisestable_rounds=when one specific task needs more room. The same applies to a task that spins onasyncio.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
RuntimeErrorrather than returning as if all were well. Raise the bound withsettle(max_rounds=...)oradvance(..., 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
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
subscribe
async
¶
on_message
¶
on_message(callback: MessageCallback) -> None
deliver
async
¶
reset
¶
Clear all recorded data, callbacks, and failure injection.
get_messages_for
¶
Return (payload, retain, qos) tuples for topic.
Source code in packages/src/cosalette/_mqtt/__init__.py
cosalette.testing.NullMqttClient
dataclass
¶
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.
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 |
{}
|
Returns:
| Type | Description |
|---|---|
Settings
|
A fully initialised :class: |
Raises:
| Type | Description |
|---|---|
TypeError
|
If an override names neither a |
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
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:
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
¶
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
MemoryStore¶
cosalette.MemoryStore
¶
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
load
¶
Return a deep copy of the stored dict, or None.
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 withbackend.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.