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, unique prefix for
a new file.
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.
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.