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¶
- Test Your Application — per-archetype usage recipes
- Testing Utilities — API reference for all test doubles
- Architecture — test seams in the composition root
- Hexagonal Architecture — Protocol-based ports and test doubles
- ADR-007 — Testing Strategy