From 616ad802147553cb715c9ae1426fb7747562d2d5 Mon Sep 17 00:00:00 2001 From: Joshua King Date: Sun, 13 Sep 2026 19:13:32 -0400 Subject: [PATCH] test: make the mock harness explicitly mock-only and fail fast with real HA --- tests/conftest.py | 100 ++++++++++++++++++++++++------------------ tests/test_harness.py | 46 +++++++++++++++++++ 2 files changed, 103 insertions(+), 43 deletions(-) create mode 100644 tests/test_harness.py diff --git a/tests/conftest.py b/tests/conftest.py index 386f7c7..a89e023 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,18 +8,21 @@ project supports >=3.11. The documented fallback is therefore used: ``pytest-asyncio`` plus a standard-shaped ``hass`` fixture below. 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 -only when they are absent installs MagicMock stand-ins via ``sys.modules`` and -wires the parent-package attributes. All of that happens here, before any -test module imports the integration, so no test module needs its own -``sys.modules`` setup. It also provides the ``hass`` fixture plus shared -helpers used by the behavior tests (config-entry factory, listener registry). +this harness is explicitly MOCK-ONLY. It unconditionally installs a coherent +set of MagicMock stand-ins via ``sys.modules`` and wires the parent-package +attributes. All of that happens here, before any test module imports the +integration, so no test module needs its own ``sys.modules`` setup. It also +provides the ``hass`` fixture plus shared helpers used by the behavior tests +(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 import asyncio -import importlib +import importlib.util import inspect import sys from types import SimpleNamespace @@ -28,7 +31,7 @@ from unittest.mock import MagicMock import pytest # --------------------------------------------------------------------------- -# Homeassistant module provisioning (mock-only fallback, real import first). +# Homeassistant module provisioning (explicitly mock-only). # --------------------------------------------------------------------------- _HA_MODULES = ( "homeassistant", @@ -37,39 +40,51 @@ _HA_MODULES = ( "homeassistant.data_entry_flow", ) -_HA_AVAILABLE: dict[str, bool] = {} -for _mod in _HA_MODULES: +def _find_spec(name: str): + """find_spec that returns None when the module (or its parent) is absent.""" try: - importlib.import_module(_mod) - _HA_AVAILABLE[_mod] = True - except ImportError: - _HA_AVAILABLE[_mod] = False + return importlib.util.find_spec(name) + except (ModuleNotFoundError, ValueError): + return None -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: - if not _HA_AVAILABLE[_mod]: - sys.modules.setdefault(_mod, MagicMock()) + # Anything already in sys.modules at load time is real HA; our mocks + # 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 - # package layout. - for _parent, _child in ( - ("homeassistant", "config_entries"), - ("homeassistant", "data_entry_flow"), - ("homeassistant", "core"), - ): - if not _HA_AVAILABLE[_parent]: - setattr(sys.modules[_parent], _child, sys.modules[f"{_parent}.{_child}"]) + +_ensure_mock_only() + +for _mod in _HA_MODULES: + sys.modules.setdefault(_mod, MagicMock()) + +# Wire parent package attributes so "from homeassistant import +# config_entries" resolves to the mocked submodule, mirroring real HA's +# package layout. +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): - """Return a module object for a mocked homeassistant module.""" + """Return the shaped mock module object for a homeassistant module.""" return sys.modules[name] 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") @@ -135,20 +150,19 @@ def _callback_decorator(fn): return fn -if not all(_HA_AVAILABLE.values()): - # The concrete 2024.6-shaped surface the integration is written against - # (mirrors the shapes the tests previously installed per module). - _config_entries_mock = _ha_mock("homeassistant.config_entries") - _core_mock = _ha_mock("homeassistant.core") - _data_entry_flow_mock = _ha_mock("homeassistant.data_entry_flow") +# The concrete 2024.6-shaped surface the integration is written against +# (mirrors the shapes the tests previously installed per module). +_config_entries_mock = _ha_mock("homeassistant.config_entries") +_core_mock = _ha_mock("homeassistant.core") +_data_entry_flow_mock = _ha_mock("homeassistant.data_entry_flow") - _config_entries_mock.ConfigFlow = _ConfigFlowBase - _config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase - _core_mock.callback = _callback_decorator - _data_entry_flow_mock.RESULT_TYPE_FORM = "form" - _data_entry_flow_mock.RESULT_TYPE_CREATE_ENTRY = "create_entry" - _data_entry_flow_mock.RESULT_TYPE_ABORT = "abort" - _data_entry_flow_mock.FlowResult = dict +_config_entries_mock.ConfigFlow = _ConfigFlowBase +_config_entries_mock.OptionsFlowWithConfigEntry = _OptionsFlowWithConfigEntryBase +_core_mock.callback = _callback_decorator +_data_entry_flow_mock.RESULT_TYPE_FORM = "form" +_data_entry_flow_mock.RESULT_TYPE_CREATE_ENTRY = "create_entry" +_data_entry_flow_mock.RESULT_TYPE_ABORT = "abort" +_data_entry_flow_mock.FlowResult = dict # --------------------------------------------------------------------------- diff --git a/tests/test_harness.py b/tests/test_harness.py new file mode 100644 index 0000000..14fe1eb --- /dev/null +++ b/tests/test_harness.py @@ -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() \ No newline at end of file