03 β Fixtures and Helpers¶
Three conftest.py files (root, integration, adapters) plus the shared
_factories.py supply everything. Know what each gives you before writing a
new test β most scaffolding you might reach to build already exists.
Root: tests/conftest.py¶
Applies to the whole suite.
| Fixture | Scope | What it gives you |
|---|---|---|
auto_enable_custom_integrations |
autouse | Wraps phac's enable_custom_integrations so HA's loader can see custom_components/eufy_vacuum. Without it, phac blocks custom integrations for isolation. You never call this β it just runs. |
brand |
function, parametrized | One run per declared brand catalog (tests/brand_catalogs.py), returning the catalog post-resolve_profile_catalog β what core consumers actually receive. A test using it must assert RELATIONSHIPS, never a literal: a literal passes for one brand and fails for the rest, which is the leak the parametrization exists to catch. |
synthetic_catalog |
function | A catalog whose words belong to no real brand. For tests that need a catalog but are not about vocabulary β dispatch, queueing, planning. If one fails only because a real brand's word is missing, that word is load-bearing somewhere it should not be. |
synthetic_adapter |
function | Registers the synthetic brand for vacuum.alfred and unregisters on teardown. For code that resolves its own catalog from the registry rather than taking one as a parameter β "a dispatch test that only passes against Eufy is testing Eufy". Teardown is not optional: the registry is global and a leftover entry silently satisfies a later test that should have failed. |
mock_config_entry |
function | A MockConfigEntry for the typical first-time setup: vacuum.alfred + a tested model + notes, empty options, unique_id = DOMAIN. |
mock_entry_no_vacuum |
function | Entry where the user skipped the vacuum entity (model only). For setup-flow edge cases. |
mock_options_entry |
function | Entry where vacuum_entity_id was set via the options flow instead of initial data. |
The hass fixture itself comes from pytest-homeassistant-custom-component β
you do not define it. Just add hass as a test parameter and you get a fresh
in-memory Home Assistant.
Integration: tests/integration/conftest.py¶
The workhorses.
manager¶
A fully initialized EufyVacuumManager, constructed directly (bypassing
async_setup_entry) so tests can exercise manager logic without entity
listeners, panels, or service registration.
What it wires for you:
- Constructs an
AdapterCoordinatorand stashes it athass.data[DOMAIN][DATA_ADAPTER_COORDINATOR], so the module-level adapter registry shims (register_adapter_config,get_adapter_config) resolve correctly. - Calls
await m.async_initialize(). - Sets
hass.data[DOMAIN][DATA_RUNTIME] = mso service handlers and the learning manager can locate it.
Use this when you are testing manager methods directly and do not need the service registry.
manager_with_services¶
manager plus every domain service registered through
async_register_services (the same path async_setup_entry uses), with
async_unregister_services on teardown.
Use this whenever your test calls hass.services.async_call(DOMAIN, ...).
async def test_something(hass, manager_with_services):
await hass.services.async_call(DOMAIN, "save_managed_rooms",
{"vacuum_entity_id": "vacuum.alfred", "map_id": "1"}, blocking=True)
assert "vacuum.alfred" in manager_with_services.data["maps"]
Learning services are not part of
async_register_services. Test files that need them register their own scoped fixture β see thelearning_servicesfixture insidetest_learning_services.py, which callsasync_register_learning_services/async_unregister_learning_services.
Seeding helpers (importable functions, not fixtures)¶
Import these from .conftest in any integration test:
| Helper | Signature | What it does |
|---|---|---|
seed_discovery |
(manager, vac, map_id, rooms) |
Pre-populates manager.data["discovery"][vac][map_id] so save_managed_rooms has something to read. |
make_rooms |
(map_id, count) -> list[dict] |
Returns count minimal discovered-room dicts (room_id, map_id, name). |
setup_map |
(manager, vac, map_id, count=3, enabled_room_ids=None) -> dict |
Seeds discovery and calls save_managed_rooms. The one-liner to get managed rooms in place. |
from .conftest import setup_map
async def test_x(hass, manager_with_services):
setup_map(manager_with_services, "vacuum.alfred", "1", count=2)
# rooms now live at manager.data["maps"]["vacuum.alfred"]["1"]["rooms"]
Per-file helpers¶
Larger test files define their own private seeding helpers at module scope.
The richest example is test_learning_services.py:
| Helper | What it seeds |
|---|---|
_seed_completed_job(hass, vac, job_id, *, room_slugs, status, used_for_learning, duration_minutes) |
Writes a completed-job payload via LearningHistoryStore. |
_seed_active_job(manager, vac, map_id, **extra) |
Writes active-job state into manager.data["active_jobs"][vac][map_id]. |
When you start a new test file for a subsystem, look for an existing file in the same domain first β the seeding helper you need has often already been written.
Adapters: tests/adapters/conftest.py¶
The adapter suite's own fixture layer. Its centerpiece is ADAPTER_BUILDERS β
a {brand: builder(hass) -> config} registry with one entry per shipped brand
(eufy, roborock), each builder registering the real adapter config the
way async_setup_entry would (Eufy: a detected_model: "T2351" vacuum state
drives the real model-family + capability-hint path; Roborock: vacuum.ivy
with supported_features 30524 matching the real device, model resolving to
the catalog DEFAULT_PROFILE). The adapter fixture is parametrized over
that registry and returns (brand_name, registered_config) β so every test
in test_adapter_contract.py runs once per brand, and adding a brand to
ADAPTER_BUILDERS runs the whole conformance suite against it with no new
test code. The registry is clear_registry()-ed per test for isolation.
Data fixtures: tests/fixtures/¶
Committed data captures (mostly real-device diagnostics) that tests load by path, grouped by consumer:
| Directory | Holds | Used by |
|---|---|---|
battery/ |
a real qualifying-charge session capture | test_battery_manager.py |
learning/ |
real completed-job records (alfred_jobs/) + idle-wall records |
test_learning_real_jobs_golden.py, test_idle_wall_guard.py |
external_run/ |
a real app-started run capture | test_external_ingest_attribution.py |
replays/ |
recorder-save bundles for the replay harness | tests/replay/test_replay_smoke.py |
When a diagnosis needed a raw capture, it was persisted here β prefer extending these over inventing a synthetic shape. See 01 β overview Β§the replay harness for how replay bundles are minted.
Shared factories: tests/_factories.py¶
Behaviour-preserving builders for the setup the suite had copy-pasted. Each is an
extraction β it produces exactly the data or effect the inline code it replaces
produced β so a refactored test asserts the identical thing. Import from anywhere
(tests is a package): from tests._factories import ....
| Helper | Signature | Replaces |
|---|---|---|
VAC / MAP / ENTRY_ID |
constants | the per-file _VAC = "vacuum.alfred" / _MAP = "1" / _ENTRY_ID = "test_entry_id" literals |
get_room_data |
(manager, room_id, *, vac=VAC, map_id=MAP) -> dict |
the copy-pasted manager.data.get("maps",{})β¦get(str(room_id),{}) lookup chain in _make_* entity builders (returns {} when absent) |
set_room_field |
(manager, room_id, *, vac=VAC, map_id=MAP, **fields) -> dict |
inline writes like manager.data["maps"][VAC][MAP]["rooms"]["1"]["enabled"] = False (mutates an existing room; does not create rooms or pop keys) |
make_manager_mock |
(*, run_profiles=None, **attrs) -> MagicMock |
the stub-manager builder (async_save as an AsyncMock, reset_maintenance / start_run_profile, a preloaded saved-run-profile library) used by button/platform tests |
spec_manager |
(**attrs) -> NonCallableMagicMock |
an autospec'd EufyVacuumManager stand-in β checks call signatures, refuses unknown attribute names, attaches the runtime instance attributes scraped from core/manager.py. Use whenever the mock is passed into our own code as a manager; use make_manager_mock when driving a platform entity against a partial stub. See 04-patterns. |
Two conventions keep refactored tests readable:
- Defaults cover what a test does NOT assert on. A test that asserts a field passes it explicitly, so "what's under test" stays visible at the call site.
- Extract from real use. Add a helper only when a live test needs it, and only if it reproduces that test's inline code exactly β never speculatively.
Route only setup through the factory; keep assertions verbatim. The helpers are
pure dict/mock operations and touch no production code, so they never change which
custom_components lines a test exercises.
Not every duplicated line belongs in the factory. A one-off
.values()loop or a bespoke per-test seed is clearer left inline β forcing it through a helper is churn, not dedup.make_mock_hass(theMagicMock()+config.config_diridiom) is deliberately not in the factory yet: a two-line block in large files, too low-value to justify a refactor.
Refactoring setup safely: scripts/diff_test_equiv.py¶
When extracting a helper from an existing test, prove the change is behaviour-preserving before it lands. The differential harness runs an untouched ORIGINAL and a factored CLONE and checks three things hold:
- same set of test names (nothing dropped, renamed, or added),
- both fully green with the same pass count,
- identical executed-line set on the module(s) under test β the objective proof the clone exercises the same code paths.
docker run --rm -v "<repo>:/workspace" -w /workspace eufy-vacuum-test \
python scripts/diff_test_equiv.py <original> <clone> \
--cov custom_components/eufy_vacuum
=> EQUIVALENT (exit 0) means the refactor is safe to cut over. Point --cov at
the specific module under test, or at the whole custom_components/eufy_vacuum
package for the strongest (line-for-line) check. The recommended flow: clone into
a staging dir excluded from the gate, validate every clone EQUIVALENT, then cut
over all at once.
Key hass.data[DOMAIN] keys¶
Fixtures and handlers locate each other through these (defined in const.py):
| Constant | Value | Holds |
|---|---|---|
DATA_RUNTIME |
"runtime" |
the core EufyVacuumManager |
DATA_LEARNING |
"learning" |
the LearningManager (lazily created) |
DATA_BATTERY |
"battery" |
the battery health manager |
DATA_ERROR_TRACKER |
"error_tracker" |
the ErrorTracker |
DATA_ADAPTER_COORDINATOR |
"adapter_coordinator" |
the active AdapterCoordinator |