test: make the mock harness explicitly mock-only and fail fast with real HA
This commit is contained in:
+57
-43
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user