04 β Patterns and Conventions¶
Follow these so new tests read like the existing ones and stay easy to map back to behavior.
Coverage-target IDs¶
Every test file opens with a docstring that enumerates coverage targets β short IDs, each naming one behavior, each owned by one test. The prefix is a mnemonic for the file.
"""Phase 4 integration tests β rooms service handlers.
Coverage targets
----------------
[SR-1] save_managed_rooms service persists room config.
[SR-2] get_vacuum_maps service returns map list for a vacuum.
[SR-3] update_room_fields service updates a field and returns ok.
[SR-4] update_room_fields service returns error for unknown room.
"""
Each test then references its target in its own docstring:
async def test_save_managed_rooms_service_persists_rooms(hass, manager_with_services):
"""[SR-1] save_managed_rooms service writes rooms into manager data."""
Why it matters: the target list is the file's table of contents and its contract. When you add a behavior, add a target ID and a test for it. When a test fails, the ID tells you which behavior broke without reading the body.
Established prefixes include LS (learning services), SR (services-rooms),
BE (button entity), and so on β one per file. Pick a short prefix for a new
file, ideally one not already used elsewhere.
IDs are file-scoped, not global. check_legend_drift.py validates each
file's legend against its own tests only β it does not enforce global
uniqueness, and genuine cross-file references are handled inline (prose) plus the
checker's CROSSREF_ALLOWLIST. So a [SP-1] in one file is a different behavior
than a [SP-1] in another; resolve an ID by the file it lives in. Reusing a
prefix across unrelated files is tolerated but discouraged β it muddies a
global grep, so prefer a fresh prefix. A prefix deliberately shared by a set
of files covering one cross-file suite (e.g. LC for lifecycle across the
listener + sensor-status tests) is fine and intentional.
Naming¶
| Thing | Convention | Example |
|---|---|---|
| Test file | test_<area>.py, grouped by subsystem or platform |
test_services_queue.py, test_listeners_timers.py |
| Test function | test_<subject>_<expected> |
test_get_vacuum_maps_service_returns_dict |
| Module constants | _VAC, _MAP at top of file |
_VAC = "vacuum.alfred" |
| Private helpers | leading underscore, module scope | _seed_active_job, _make_manager |
vacuum.alfred is the standard test vacuum across the suite. Reuse it.
Calling a service¶
result = await hass.services.async_call(
DOMAIN,
"service_name",
{"vacuum_entity_id": _VAC, "map_id": _MAP},
blocking=True,
return_response=True, # only for services that return data
)
blocking=Trueβ wait for completion before asserting.return_response=Trueβ required for services that return a payload (the read/snapshot services). Omit it for fire-and-forget services, which returnNone.
After a service that schedules background work, drain the loop before asserting:
Sync manager methods: run them in the executor¶
Many manager methods are synchronous and do blocking file I/O through the
store (finalize_completed_job, the rebuilders, snapshot writes). HA forbids
blocking the event loop, so call them through the executor:
result = await hass.async_add_executor_job(
lambda: learning.finalize_completed_job(
manager=core_manager,
vacuum_entity_id=_VAC,
map_id=_MAP,
battery_start=85, battery_end=60,
started_at="2026-01-01T09:00:00+00:00",
ended_at="2026-01-01T09:30:00+00:00",
used_for_learning=False,
rebuild_stats=False,
)
)
The async wrappers (e.g. async_finalize_completed_job) do this internally β
prefer them when one exists. Drop to the sync method + executor only when you
are specifically testing the sync path.
Unit tests: mock the manager¶
Entity and platform unit tests do not need a real manager β they need an object
that records calls. Build a MagicMock, with AsyncMock for the async methods:
from unittest.mock import AsyncMock, MagicMock
def _make_manager(*, run_profile_data=None):
manager = MagicMock()
manager.async_save = AsyncMock() # awaited by the entity
manager.reset_maintenance = MagicMock() # sync
manager.get_saved_run_profiles.return_value = {"library": run_profile_data or {}}
return manager
Then assert on the recorded interaction:
manager = _make_manager()
button = _make_reset_button(manager)
await button.async_press()
manager.reset_maintenance.assert_called_once()
manager.async_save.assert_awaited_once()
Use this for anything whose logic is "translate an HA call into a manager call" β buttons, switches, numbers, sensors. It is faster and more precise than standing up the full manager.
β¦but use spec_manager() when the mock is handed to OUR code¶
The pattern above is right for driving a platform entity against a
deliberately partial stub. It is the wrong tool when the mock is passed into
our own code as a manager β PhaseRunner(manager=mgr), RoomMapManager(mgr),
finalizer._collect_finalization_inputs(manager=mgr, β¦) β because a bare
MagicMock agrees with the caller, not the callee:
- it answers to any attribute name, whether or not the real class has one.
mgr.learningdoes not exist onEufyVacuumManager(it is_get_learning_manager()), and a mock inventing it made a dead path look exercised; - it accepts any argument list.
_collect_finalization_inputswas called without three required keyword-only args; the permissive stub swallowed theTypeErrorand two live runs wrote no child records while the suite stayed green.
from tests._factories import spec_manager
mgr = spec_manager() # autospec'd against EufyVacuumManager
mgr.get_active_job.return_value = {...} # stub what the test needs
runner = PhaseRunner(manager=mgr)
spec_manager builds create_autospec(EufyVacuumManager, instance=True), so
call signatures are checked against the real functions and unknown names raise.
Instance attributes (data, hass, active_job, β¦) are invisible to autospec
because they are assigned at runtime, so they are scraped from
core/manager.py and attached β not hand-listed, because a hand list is a
second source of truth that goes stale silently (the first draft of the helper
proved it, failing 17 tests that had nothing wrong with them).
One consequence worth knowing: autospec makes the manager's async def methods
AsyncMocks, so hass.async_create_task(mgr._async_save_logged()) now builds a
real coroutine. spec_manager closes it, or Python reports "coroutine β¦ was
never awaited" against whichever test the GC happened to interrupt.
tests/unit/test_factories_spec_surface.py is the guard on the guard β it pins
that unknown attributes raise, that bad signatures raise, and (as a control) that
a bare MagicMock accepts both.
Pin discipline: test the contract, not the shape¶
The mirror image of the mock rule above, discovered by the CAL-23 blind-reconstruction calibration (2026-08-07). The two diseases:
| what lies | failure mode | constrains | |
|---|---|---|---|
| Mock disease | a fake collaborator agrees with the caller | test PASSES while the code is broken | under-constrains behavior |
| Pin disease | a real-code test asserts private internals by name | test FAILS while the code is correct | over-constrains implementation |
A test that drives t._record_rising_edge(...) or asserts t._grace_cancels == {} is
pinning the implementation's shape: any correct reimplementation that names or
structures its internals differently fails, while behaving identically. The acid
question for every new test: would this test accept a correct reimplementation?
If no, it asserts the wrong contract. Prefer the public surface β the constructor,
the public methods, the emitted state/attributes, the injected-closure seams β and
treat any direct ._name access as a deliberate exception that needs a comment
saying why the public surface can't force the behavior.
Over-pinning also steers production: core/error_tracker.py keeps the deprecated
harvest_active_run alive solely because tests assert its semantic (its docstring
says so) β the suite driving the code instead of guarding it.
Current, honestly stated: tests/integration/test_core_error_tracker.py is the
known worst case β 35 of its 43 tests touch private names (census 2026-08-07).
They guard real behavior today and are NOT being rewritten opportunistically
(suite-freeze ruling); they are quarantined from blind-reconstruction verdicts and
queued as a hardening class. New tests follow this section from now on β the
ratchet direction is: white-box count may only shrink.
Assertions: prefer presence over exact equality¶
Because the integration hass shares its config_dir across tests in a run
(see 05), seeded jobs and stats accumulate.
Assert on what your test added, not on totals:
# Good β robust to accumulation
assert result.get("available") is True
assert result["overview"]["job_stats"]["total_jobs"] >= 1
assert any(j["job_id"] == "j-flt-001" for j in jobs)
# Fragile β breaks when another test seeds a job
assert result["overview"]["job_stats"]["total_jobs"] == 1
What not to test¶
The suite deliberately skips:
- Pure log-only / best-effort
exceptblocks β a block whose only effect is a log line (best-effort I/O writes, listener teardown). These carry# pragma: no coverinstead (see below). - Inactive / dead code paths β functions that currently always return early,
or guards whose conditions are mutually exclusive (e.g. the documented dead
branch in
core/manager.py's progress snapshot). - A real device or live HA entity setup β e.g. the live-entity path of the
discover_roomsservice, which drives real adapter entities. The handler itself is covered with a mock manager (test_services_rooms.pytest_discover_handler_success/test_discover_handler_raises, SR-5/SR-6); only the live-entity path is left to a full integration boot, the same placeasync_setup_entryboot wiring runs. - Defensive
continue/return []normalization guards β left as honest misses (real control flow, so not pragma'd), since a test that feeds garbage to assert it's skipped asserts plumbing, not behavior.
Coverage of those costs more than it protects. Spend the effort on real behavior.
But DO test an except that changes the surfaced result¶
The opposite of the first bullet: an except block is behavior β and worth
a test β when it does more than log. Test it when the failure path:
- wraps the error as
HomeAssistantError/ServiceValidationError(the HA Silver action-exception contract) βmonkeypatchthe manager method to raise, then assert the wrapped type (seetest_services_run_profiles.pySRN-11,test_services_maintenance_reset.pyMR-4/5); - returns a degraded field the caller sees (e.g.
start_selected_rooms'learning_snapshot: {saved: False, reason: snapshot_error},SS-7); - skips one item and continues a fan-out loop (a failing update callback must
not block the rest β
MD-7).
The rule of thumb: if removing the except would change what a caller observes,
it's behavior; if it would only change the logs, it's a # pragma: no cover.
Coverage exclusions (# pragma: no cover)¶
.coveragerc excludes pragma: no cover lines. Put it on the except line
itself (not the log line) so the whole branch drops, and append a short reason:
except OSError as err: # pragma: no cover - best-effort I/O, logs and swallows
_LOGGER.debug("β¦failed to write %s: %s", path, err)
Use it surgically, one audited block at a time β never a blanket _LOGGER.*
regex, which would also silence the behavioral excepts above and leave
half-excluded branches under --cov-branch. The full convention is in
subsystems/README.
Standing gates on things that are not code¶
Five committed gates, holding ground that no ordinary test can. They divide into two kinds, and the distinction matters when you touch one:
Ratchets β mock debt and documentation β are SHRINK-ONLY: the number may fall freely, and raising it is meant to be harder than fixing the thing it measures.
Absolute gates β front-page links, the register, and screenshot freshness β have no allowance. They pass or they do not.
This heading read "The two ratchets (mock debt, doc coverage)" and opened "Two committed gates" while three were listed beneath it. Each new gate was appended without touching the frame above, so the count was wrong for as long as the section had been growing β the same accretion failure the register ratchet exists to catch, in the prose describing it.
Mock ratchet β tests/test_mock_ratchet.py¶
A bare MagicMock() / AsyncMock() agrees with the caller, not the callee:
every attribute exists, every method returns another mock, every shape assertion
passes. Audit 1 traced four live failures to exactly that, green all the way to
hardware. spec_manager() and create_autospec are the cure (see
spec_manager); this gate measures where they are
not yet used.
tests/mock_allowlist.json records a per-file CEILING for the 40 files that
currently carry bare mocks. A file not on that list may have zero β that is
the half that matters, because the debt is concentrated and what must be
prevented is it reappearing in the ~145 files that are clean.
This is not a ban. Entity-driving partial stubs are correct and stay (above). The doctrine's line is a mock handed INTO production code must be spec'd, which needs per-site judgement; the gate only holds the total steady while that happens.
python scripts/mock_census.py # the current profile, worst first
python scripts/mock_census.py --write-allowlist # bank progress AFTER converting
Front-page link gate β tests/unit/test_readme_links.py¶
mkdocs.yml sets docs_dir: docs and builds --strict, so every link inside
docs/ is validated on each Pages build. README.md sits at the repository ROOT,
which puts it outside that gate entirely β no CI job reads it at all.
The gap was not theoretical. The README pointed at β¦/docs/dev/27-render-harness/
after that file was renamed to docs/dev/frontend/render-harness.md. There is no
redirects plugin and the Pages deploy replaces the whole artifact, so it had been a
hard 404 since the rename, found eventually by a hand audit.
Four checks, all offline: every repo-relative path exists on disk; every published
docs-site URL maps to a real source file under docs/ (including the section
landings, which are README.md, not index.md); every in-page anchor matches a
heading that still exists; and reference-style definitions and uses match in both
directions. External URLs are deliberately NOT checked β they need the network and
other people's repos are allowed to move.
Retiring a screenshot or renaming a docs page now fails here instead of on the front page.
Register ratchet β tests/test_replica_ratchet.py¶
A notation anchor and its registry must not drift apart. Three checks, each ablated and confirmed to go red on its own and nothing else:
[RR-1] |
an anchor declared in source has a ### \TOKEN`entry in00b(IN) or00c` (RN) |
[RR-2] |
a registry entry names a real anchor site β 00b's own standard, enforced |
[RR-3] |
each RN set has exactly one anchor: primary and at least one REPLICA |
The hole it closes is specific and was live. On 2026-08-18 seven RN anchors were
declared, marked at both members, and present in no document at all, while
doc_anchor --check reported 0 problems and --orphans reported 0 orphans. --orphans
asks "does any document cite this anchor?", and a REPLICA marker in source satisfies
it β so a set can be fully wired in code and invisible in the register, permanently, with
every shipped gate green.
[RR-3] covers the other invisible state: three placements during the census briefly left a
primary with no replica. A set of one is not a set, and nothing else can see it.
It imports doc_anchor.scan() rather than re-scanning. A second parser is a second
answer to "what counts as declared" and will diverge β while writing this, a hand-rolled
regex requiring a #////* prefix reported INMKEHPQ and INSJM6KC as unregistered.
Both were fine; INSJM6KC is declared inside a docstring. A ratchet that cries wolf gets
deleted, so the tool owns the question and the test owns only the comparison.
CN is deliberately exempt β 00b indexes IN and 00c indexes RN; code notation has no
registry by design.
Screenshot freshness β scripts/check-screenshot-freshness.test.mjs¶
A committed screenshot is a claim about the product that no test reads.
docs/screenshots/translations-hero-profile-card.png rendered "RUNS AS";
run_profiles.runs_as became "Runs in this order" three days after that image was
committed. The README carried the outdated wording for six weeks and shipped it in
v2.1.0. Every gate here checks code, and a screenshot is not code.
Note how it differs from the front-page link gate above, which is the closest thing: that one proves the image path resolves. This proves the pixels still match the product. A retired screenshot fails there; a stale one fails here.
Not OCR. scripts/screenshot-i18n-manifest.json declares which i18n keys each family
renders and fingerprints their English values; the check fails when one moves and
names the key, the new value, and the files to re-shoot. It cannot say an image is
wrong β only that the strings under it changed, which is the signal a human needs.
Key sets are derived, not hand-listed: static t("β¦") literals are extracted from
each card's own source, so a card that starts using a new string is covered without
anyone remembering to edit the manifest β "remember to update the manifest" being the
same failure mode as "remember to re-shoot". The one unextractable case is the dynamic
t(`vocab.${field}.${slug}`); those four fields are declared literally at the
chipRow() call sites, so the manifest names them rather than sweeping all 682
vocab.* keys and flagging every image on an unrelated fault-vocabulary edit.
node scripts/check-screenshot-freshness.mjs # the gate
node scripts/check-screenshot-freshness.mjs --update # AFTER re-shooting, never before
β --update before re-shooting silently disarms it forever. It re-records whatever
is in en.js now, so it will happily bless images that are still stale, and read green
from then on.
Declared blind spot: the maintenance guide prose inside Filter_*.png is
model-aware content that does not live in en.js, so only that card's chrome is
fingerprinted. A guide rewrite will not flag those images. Layout, theme, font and
non-English values are all out of scope too β this watches English string values only.
Re-shooting is cheap by design: the shots come from a permanent dashboard with one
card pinned per language. See .claude/notes/WORKFLOW-screenshots-and-languages.md.
Documentation ratchet β tests/test_docs_ratchet.py¶
A test file that appears nowhere in docs/testing/ produces no findings and reads
exactly like a well-covered one β the same shape of lie as the mock, one level up.
tests/undocumented_tests.json lists the current backlog; a new test file must
be mentioned in its subsystem page in the same commit that creates it.
β It sees Python only. census() walks tests/, so harness/tests/*.spec.mjs,
harness/lib/*.test.mjs and scripts/*.test.mjs are outside the ratchet entirely β a
node test file can be created undocumented and nothing says so. Measured 2026-08-25: of
31 node-side test files, 22 are mentioned somewhere under docs/, 9 are mentioned
nowhere:
harness/lib/animal-gallery-html, harness/lib/landing-html,
scripts/animal-js-to-descriptor, scripts/bundled-animals,
scripts/check-animal-pr, scripts/gen-theme-token-docs,
scripts/process-animal-submission, scripts/sanitize-animal-svg,
scripts/svg-to-descriptor.
(It was 10 when first measured. Documenting the screenshot gate above moved it off the list β which is exactly how small this fix is per file, and why nine of them have sat there unnoticed.)
That is the ratchet's own stated blind spot β "an undocumented subsystem and a clean
one read identically" β reproduced one level up, in the scope of the gate rather than
inside it. The bar also differs: Python is enforced against docs/testing/
specifically, while the node figures above count a mention anywhere under docs/,
which is the more generous test.
The generated Mocking column¶
Each subsystem coverage table carries a Mocking cell derived from the same
census that feeds the ratchet β so the docs and the gate cannot disagree. It
aggregates a row's test files and reports the risk (bare xN), not the pedigree:
clean means nothing in that row constructs an unspec'd mock. The Layer column
says unit-vs-integration, which is not the axis that bites.
Generated, so hand edits are overwritten:
python scripts/mock_docs.py # rewrite the column
python scripts/mock_docs.py --check # CI-style staleness check, writes nothing
Both ratchets and the column live in tests/test_mock_ratchet.py,
tests/test_docs_ratchet.py, scripts/mock_census.py and scripts/mock_docs.py.
Generated documentation β the staleness gate¶
A generated document cannot drift the way prose does. It drifts by not being regenerated, and that is indistinguishable from being correct: the file is well-formed, the numbers are specific, and nothing about it looks old.
Both generated surfaces in this repo were stale when the gate was written, and neither had ever failed anything:
| surface | state found | why nothing caught it |
|---|---|---|
docs/dev/reference/THEME_TOKEN_USAGE.md |
651 lines out of date after 31 commits to src/styles/; every file:line citation wrong (index.js:324 had become :393) |
the generator was run by hand, last on 2026-08-11 |
the Mocking column in subsystems/15-adapters.md, 17-services.md |
two rows stale | mock_docs.py --check existed, documented as "CI: fail if stale", and was wired into nothing |
The second one is the sharper lesson: the check was already written and already correct. Writing a checker is not the same as running one.
scripts/check_generated_docs.py holds a registry of every generator and compares
what is in the tree against what the generator emits now.
python scripts/check_generated_docs.py # check
python scripts/check_generated_docs.py --fix # regenerate everything, then re-check
Five findings, each ablated in tests/unit/test_generated_doc_gate.py (GDG-1..GDG-16)
β a clean run against a clean tree proves nothing about a detector that is silently
dead:
STALE |
the generator now emits something else |
MISSING |
a registered file is not in the tree |
SILENT |
the generator exited 0 and wrote nothing, or wrote the wrong name |
BROKEN |
it could not be run, timed out, or failed |
UNGATED |
a file carrying the GENERATED FILE banner that no registry entry claims β the omission failure, so adding a generator and forgetting to register it fails here rather than nowhere |
Entries come in two shapes. Whole-file generators write complete documents and
name an env var this gate sets to redirect them into a scratch tree, so the check
never touches the working copy β one that wrote over the tracked files and restored
them would leave the tree dirty on exactly the run that failed. Region generators
rewrite a block inside a hand-written page (the Mocking column), so there is
nothing to redirect; they bring their own --check and a non-zero exit means stale.
A command that cannot be run at all is BROKEN, never STALE β those are opposite
instructions, and reporting a missing node as "your docs are stale" sends the
reader to regenerate a document that was already current.
This one IS a CI gate, unlike check_docs_index.py, and the
difference is not an inconsistency. That script is a doc-commit rule because a new
document legitimately lands before the index pass that files it, so gating on it
would fail pushes for work that is not yet due β the 2026-06-12 check_legend_drift.py
ruling. Staleness here is the opposite shape: it is caused by a code change, the
fix is one command with no editorial judgement in it, and there is no later pass
that is supposed to catch up. Same reason check-styles.mjs gates the build. It runs
as the generated docs job in tests.yml.
Line-precise citations are what a generated doc is FOR, and this gate is what makes them safe. The standing rule that prose cites symbols rather than line numbers exists because prose has no mechanism to stay current. A generator does β provided something runs it.
Line-number citations are banned β scripts/check_doc_citations.py¶
A file.py:187 citation is the cheapest claim a doc can make and the only one that
rots without anyone touching the document: one import added at the top of a
module invalidates every citation below it, everywhere.
What makes it a trap rather than a nuisance is that a rotted line number still
resolves. capabilities.py::_find_registry_entity_by_tokens lands on return entry.entity_id β real code, in
the right file, plausible. A broken link announces itself; this reads as correct.
Measured across docs/ on 2026-08-15:
file.py:N citations |
664 |
file.py::symbol citations |
6 |
| line citations that could be verified (a symbol was named beside them) | 41 |
| β¦of those, wrong | 36 (88%) |
| line citations with nothing naming what they point at β unverifiable | 623 |
Three of the failures were broken that same morning, by commits to the code the doc describes.
So the rule is the form, not the correctness: prose cites symbols, never line
numbers. file.py::symbol survives any refactor that does not rename the thing,
and the checker prints the replacement for every citation it can resolve. Where
nothing names the target, degrade to the bare file.py rather than reverse-engineer
a pointer that may already be wrong β a file-level citation cannot rot, and the
precision being given up was fictional.
Testing a generator: assert on the DOCUMENT¶
EVT-1..EVT-3 in the same file cover scripts/gen_event_docs.py, and they read
the emitted markdown rather than anything the generator printed about itself. That
distinction is the whole point. The bug they pin had reached the output: where two
fire sites built a payload key differently, only the first expression survived, so
17 slots printed one expression as though it were the only one β source and
trigger among them, the keys an automation actually branches on. Every count the
generator reported was correct while the document was wrong, so no stdout assertion
could have seen it.
They run against a synthetic three-file package via EVCC_EVT_ROOT, not the real
integration, so ordinary churn cannot fail them for an unrelated reason. EVT-3
asserts the blind-spot ledger is present and non-empty: a generated page that
declares no blind spots reads as one that has none, which is how
THEME_TOKEN_USAGE.md once reported 135 live theme tokens as dead.