test: make the mock harness explicitly mock-only and fail fast with real HA

This commit is contained in:
Joshua King
2026-09-13 19:13:32 -04:00
parent ba994c2648
commit 616ad80214
2 changed files with 103 additions and 43 deletions
+57 -43
View File
@@ -8,18 +8,21 @@ project supports >=3.11. The documented fallback is therefore used:
``pytest-asyncio`` plus a standard-shaped ``hass`` fixture below. ``pytest-asyncio`` plus a standard-shaped ``hass`` fixture below.
conftest.py is the single place where ``homeassistant`` modules are provided: conftest.py is the single place where ``homeassistant`` modules are provided:
it first tries to import the real packages (importlib in a try/except) and this harness is explicitly MOCK-ONLY. It unconditionally installs a coherent
only when they are absent installs MagicMock stand-ins via ``sys.modules`` and set of MagicMock stand-ins via ``sys.modules`` and wires the parent-package
wires the parent-package attributes. All of that happens here, before any attributes. All of that happens here, before any test module imports the
test module imports the integration, so no test module needs its own integration, so no test module needs its own ``sys.modules`` setup. It also
``sys.modules`` setup. It also provides the ``hass`` fixture plus shared provides the ``hass`` fixture plus shared helpers used by the behavior tests
helpers used by the behavior tests (config-entry factory, listener registry). (config-entry factory, listener registry).
Run this suite in its own venv *without* homeassistant installed (``uv run
pytest -q``); it is never meant to run against a real HA install.
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import importlib import importlib.util
import inspect import inspect
import sys import sys
from types import SimpleNamespace from types import SimpleNamespace
@@ -28,7 +31,7 @@ from unittest.mock import MagicMock
import pytest import pytest
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Homeassistant module provisioning (mock-only fallback, real import first). # Homeassistant module provisioning (explicitly mock-only).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_HA_MODULES = ( _HA_MODULES = (
"homeassistant", "homeassistant",
@@ -37,39 +40,51 @@ _HA_MODULES = (
"homeassistant.data_entry_flow", "homeassistant.data_entry_flow",
) )
_HA_AVAILABLE: dict[str, bool] = {} def _find_spec(name: str):
for _mod in _HA_MODULES: """find_spec that returns None when the module (or its parent) is absent."""
try: try:
importlib.import_module(_mod) return importlib.util.find_spec(name)
_HA_AVAILABLE[_mod] = True except (ModuleNotFoundError, ValueError):
except ImportError: return None
_HA_AVAILABLE[_mod] = False
if not all(_HA_AVAILABLE.values()):
# Mock-only branch: install MagicMock stand-ins for the missing modules. def _ensure_mock_only():
"""Fail fast if real homeassistant is importable: this harness is mock-only."""
for _mod in _HA_MODULES: for _mod in _HA_MODULES:
if not _HA_AVAILABLE[_mod]: # Anything already in sys.modules at load time is real HA; our mocks
sys.modules.setdefault(_mod, MagicMock()) # are only installed after this check.
if _mod in sys.modules or _find_spec(_mod) is not None:
raise pytest.UsageError(
"The NestQuest test harness is mock-only: real homeassistant is "
f"importable ({_mod!r} found), and mixing real HA modules with the "
"shaped mocks is not supported. Run the suite in its own venv "
"without homeassistant installed, e.g. `uv run pytest -q`."
)
# Wire parent package attributes so "from homeassistant import
# config_entries" resolves to the mocked submodule, mirroring real HA's _ensure_mock_only()
# package layout.
for _parent, _child in ( for _mod in _HA_MODULES:
("homeassistant", "config_entries"), sys.modules.setdefault(_mod, MagicMock())
("homeassistant", "data_entry_flow"),
("homeassistant", "core"), # Wire parent package attributes so "from homeassistant import
): # config_entries" resolves to the mocked submodule, mirroring real HA's
if not _HA_AVAILABLE[_parent]: # package layout.
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) for _parent, _child in (
("homeassistant", "config_entries"),
("homeassistant", "data_entry_flow"),
("homeassistant", "core"),
):
setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"])
def _ha_mock(name: str): def _ha_mock(name: str):
"""Return a module object for a mocked homeassistant module.""" """Return the shaped mock module object for a homeassistant module."""
return sys.modules[name] return sys.modules[name]
def options_flow_base(): def options_flow_base():
"""Return the active OptionsFlowWithConfigEntry class (real or mocked).""" """Return the mocked OptionsFlowWithConfigEntry class."""
return getattr(sys.modules["homeassistant.config_entries"], "OptionsFlowWithConfigEntry") return getattr(sys.modules["homeassistant.config_entries"], "OptionsFlowWithConfigEntry")
@@ -135,20 +150,19 @@ def _callback_decorator(fn):
return fn return fn
if not all(_HA_AVAILABLE.values()): # The concrete 2024.6-shaped surface the integration is written against
# The concrete 2024.6-shaped surface the integration is written against # (mirrors the shapes the tests previously installed per module).
# (mirrors the shapes the tests previously installed per module). _config_entries_mock = _ha_mock("homeassistant.config_entries")
_config_entries_mock = _ha_mock("homeassistant.config_entries") _core_mock = _ha_mock("homeassistant.core")
_core_mock = _ha_mock("homeassistant.core") _data_entry_flow_mock = _ha_mock("homeassistant.data_entry_flow")
_data_entry_flow_mock = _ha_mock("homeassistant.data_entry_flow")
_config_entries_mock.ConfigFlow = _ConfigFlowBase _config_entries_mock.ConfigFlow = _ConfigFlowBase
_config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase _config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase
_core_mock.callback = _callback_decorator _core_mock.callback = _callback_decorator
_data_entry_flow_mock.RESULT_TYPE_FORM = "form" _data_entry_flow_mock.RESULT_TYPE_FORM = "form"
_data_entry_flow_mock.RESULT_TYPE_CREATE_ENTRY = "create_entry" _data_entry_flow_mock.RESULT_TYPE_CREATE_ENTRY = "create_entry"
_data_entry_flow_mock.RESULT_TYPE_ABORT = "abort" _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort"
_data_entry_flow_mock.FlowResult = dict _data_entry_flow_mock.FlowResult = dict
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+46
View File
@@ -0,0 +1,46 @@
"""Contract test for the mock-only homeassistant harness in conftest.py."""
from __future__ import annotations
import importlib.metadata
import sys
from unittest.mock import MagicMock
import pytest
import conftest
from conftest import _HA_MODULES, _ensure_mock_only
def test_mock_only_harness_contract(monkeypatch):
"""The harness is mock-only: shaped mocks everywhere, fail-fast on real HA."""
# Every required homeassistant module in sys.modules is a mock stand-in
# with the shaped surface, and parent-package attributes point at them.
for name in _HA_MODULES:
assert isinstance(sys.modules[name], MagicMock), f"{name} is not a mock"
config_entries = sys.modules["homeassistant.config_entries"]
assert config_entries.ConfigFlow is conftest._ConfigFlowBase
assert config_entries.OptionsFlowWithConfigEntry is (
conftest._OptionsFlowWithConfigEntryBase
)
assert sys.modules["homeassistant"].config_entries is config_entries
assert sys.modules["homeassistant.core"].callback is conftest._callback_decorator
assert sys.modules["homeassistant.data_entry_flow"].RESULT_TYPE_FORM == "form"
# No homeassistant distribution is installed in this suite's own venv.
with pytest.raises(importlib.metadata.PackageNotFoundError):
importlib.metadata.version("homeassistant")
# Fail-fast paths: a findable real HA module, or one preloaded in
# sys.modules, both abort collection with a UsageError.
real_spec = MagicMock(origin="/fake/site-packages/homeassistant/__init__.py")
monkeypatch.setattr(conftest, "_find_spec", lambda name: real_spec)
with pytest.raises(pytest.UsageError, match="mock-only"):
_ensure_mock_only()
monkeypatch.setattr(conftest, "_find_spec", lambda name: None)
monkeypatch.setitem(sys.modules, "homeassistant", MagicMock(name="real-module"))
with pytest.raises(pytest.UsageError, match="mock-only"):
_ensure_mock_only()