Skip to content

Testing Strategy

Cosalette provides a three-layer testing strategy with purpose-built test doubles and a pytest plugin — making it straightforward to test device code without a real MQTT broker or hardware.

Three Layers

Layer What you test Dependencies Speed
Domain Pure business logic None — no cosalette imports Fastest
Device Device functions + MQTT flow cosalette.testing fixtures Fast
Integration Full app lifecycle AppHarness (all test doubles) Fast

Sociable Over Isolated

Cosalette follows the sociable unit test philosophy: test collaborating objects together rather than mocking every boundary. Device tests use real DeviceContext with a MockMqttClient — testing the actual publish flow, not a mock of it.

Why sociable?

Isolated unit tests that mock every dependency tend to test the mocking framework, not the code. Sociable tests catch integration issues early while remaining fast because the test doubles are lightweight in-memory implementations.

Test Doubles

Cosalette ships five test doubles, each targeting a different boundary:

Double Boundary Why it exists
MockMqttClient MQTT broker Records all publishes/subscribes so tests can assert on them
FakeClock System time Deterministic uptime and strategy timing without real delays
ManualClock System time Gates every sleep so a scheduled tick cannot fire unasked
NullMqttClient MQTT broker Silent no-op for cases where MQTT output is irrelevant
make_settings() Configuration Strips env/dotenv sources so settings are reproducible

The doubles satisfy the same Protocol interfaces as their production counterparts. Production code and test code share the same function signatures — no conditional logic and no separate test paths.

FakeClock proves what happened, not what didn't

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. That makes it a sound instrument for what did happen and an unsound one for what did not: a test cannot prove that a scheduled tick was absent, and an exact publish count only measures how many event-loop yields the test happened to burn. Discriminate a trigger-initiated run from a scheduled tick with TriggerPayload.is_triggered. ADR-071 records the direction for closing this gap.

ManualClock proves what didn't happen

ManualClock.sleep() registers a per-sleeper deadline and blocks on an event that only advance(seconds) sets, so a scheduled tick cannot fire unless the test asks for it. settle() drains the event loop without moving virtual time, so "nothing published yet" rests on the gate rather than on how many yields the test burned. Per-sleeper deadlines also mean concurrent tasks no longer contribute to each other's timelines.

Quiescence is a heuristic — asyncio exposes no supported idle hook — so settle() watches the pending tasks, the pending deadlines and a clock activity counter, declares quiescence only after three consecutive unchanged rounds, and raises loudly when a bounded retry runs out. It fails silently in the other direction: a task taking a few plain await hops between being released and its observable effect can be reported quiescent before it finishes, so prefer asserting the state you expect after advance() or settle(until=...) over asserting the absence of an effect after a bare settle(). See the Testing Utilities reference for the full contract.

See the Testing Utilities reference for full API docs, and the Test Your Application guide for usage recipes including MockMqttClient failure injection, FakeClock time control, and harness assembly patterns.

AppHarness

The AppHarness is the highest-level test utility. It pre-wires an App with MockMqttClient, FakeClock, and isolated Settings, then exposes a single run() method that exercises the complete _run_async() lifecycle. Integration tests use it as a one-liner entry point rather than assembling doubles manually.

See the Test Your Application guide for usage recipes and the Testing Utilities reference for the full API.

Test Seams in _run_async()

The framework's _run_async() method accepts optional parameters specifically designed as injection points: settings, shutdown_event, mqtt, and clock. When a parameter is None, the real implementation is used; when provided, the double replaces it for that run only.

This design means:

  • No test flag or mode switch — production and test code call the same method.
  • Opt-in granularity — inject only the doubles you need; leave the rest real.
  • AppHarness assembles them automatically — the direct injection pattern exists for advanced isolation tests that need finer control.

See Testing Utilities reference for the parameter table.


See Also