Skip to content

Commit 10e12b5

Browse files
0xAHAclaude
andcommitted
Add Home Assistant integration test suite, running in CI
The class of bug that has cost the most time this week is invisible to the existing suite: it deliberately avoids Home Assistant, so anything in a voluptuous schema or the entry lifecycle cannot be reached. The Max Register Block Size selector shipped broken in v1.2.0 and survived four releases. The read path was wired correctly, so inspecting the code looked fine - the value simply never reached it. Two users found it independently as two different symptoms (#360, #367), and I told the second one the code was correct. tests_ha/ covers exactly that gap: test_options_flow.py every offered block-size label persists; the saved value reaches the read path; the form opens with a valid default; an unrelated option can still be changed test_setup.py setup, unload, reload, entities created, coordinator on runtime_data rather than hass.data, diagnostics produced without raising and with the host redacted Those setup/unload/reload cases are the checks I have been asking a user to perform by hand after every release - the reason v1.3.1 and v1.3.2 both shipped as pre-releases. Kept as a separate directory and a separate CI job on purpose. tests/ has three small dependencies and runs in half a second, which is what makes it usable on every register-map change; pulling Home Assistant into that would lose it. Linux only. Home Assistant pins lru-dict==1.3.0, which has no CPython 3.13 Windows wheel and needs a C compiler, so these cannot run on the development machine. CI is the only place they execute, and the first run may well need iteration. pytest-homeassistant-custom-component is pinned to 0.13.316 deliberately - each release targets one HA version, so leaving it floating lets an upstream release break CI with no change here. tests/conftest.py now checks whether Home Assistant is importable rather than already imported, so its stub steps aside instead of shadowing the real package. pytest.ini sets testpaths=tests so a bare pytest run does not try to collect tests_ha and fail on a missing dependency. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4de0ae5 commit 10e12b5

7 files changed

Lines changed: 335 additions & 9 deletions

File tree

.github/workflows/tests.yaml

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,45 @@ on:
77
branches: [main]
88

99
jobs:
10-
test:
10+
# Fast suite: pure protocol logic, no Home Assistant. Runs in well under a second,
11+
# which is what makes it usable on every register-map change.
12+
unit:
13+
name: Unit tests (no HA)
1114
runs-on: ubuntu-latest
1215
steps:
1316
- uses: actions/checkout@v4
1417

15-
- name: Set up Python
16-
uses: actions/setup-python@v5
18+
- uses: actions/setup-python@v5
1719
with:
1820
python-version: "3.12"
1921

20-
# Home Assistant is deliberately NOT installed. The protocol layer is
21-
# HA-free, and tests/conftest.py stubs the one unused import so it stays
22-
# that way — keeping the suite fast and the coupling visible.
2322
- name: Install dependencies
2423
run: pip install pytest pymodbus pyserial
2524

2625
- name: Run tests
2726
run: pytest tests/ -v
27+
28+
# Home Assistant suite: config flow, setup/unload, entity creation, diagnostics.
29+
#
30+
# Linux only. Home Assistant pins lru-dict==1.3.0, which has no CPython 3.13 Windows
31+
# wheel and needs a C compiler — so this cannot run on a Windows dev machine, and CI
32+
# is the only place these tests execute.
33+
#
34+
# pytest-homeassistant-custom-component is pinned deliberately: each release targets
35+
# one specific HA version, so leaving it floating means an upstream HA release can
36+
# break this job without any change here. Bump it on purpose.
37+
homeassistant:
38+
name: Home Assistant integration tests
39+
runs-on: ubuntu-latest
40+
steps:
41+
- uses: actions/checkout@v4
42+
43+
- uses: actions/setup-python@v5
44+
with:
45+
python-version: "3.13"
46+
47+
- name: Install Home Assistant test harness
48+
run: pip install pytest-homeassistant-custom-component==0.13.316
49+
50+
- name: Run tests
51+
run: pytest tests_ha/ -v

pytest.ini

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
[pytest]
2+
# Bare `pytest` runs only the fast, Home-Assistant-free suite. Running it from the repo
3+
# root would otherwise try to collect tests_ha/ and fail on a missing
4+
# pytest-homeassistant-custom-component, which is confusing rather than informative.
5+
#
6+
# The Home Assistant suite is opt-in: pytest tests_ha/
7+
# It requires Linux — see tests_ha/conftest.py for why it cannot run on Windows.
28
testpaths = tests
3-
python_files = test_*.py
4-
python_classes = Test*
5-
python_functions = test_*
9+
10+
# tests_ha/ uses async test functions without explicit markers.
11+
asyncio_mode = auto

tests/conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from __future__ import annotations
2020

2121
import importlib
22+
import importlib.util
2223
import sys
2324
import types
2425
from pathlib import Path
@@ -30,6 +31,12 @@
3031

3132

3233
def _stub_homeassistant() -> None:
34+
# Step aside when a real Home Assistant is installed. Checking importability rather
35+
# than "already imported" matters: pytest loads this conftest before anything touches
36+
# HA, so an unconditional stub would shadow the real package for the whole session
37+
# and break the tests_ha/ suite in confusing ways.
38+
if importlib.util.find_spec("homeassistant") is not None:
39+
return
3340
if "homeassistant" in sys.modules:
3441
return
3542
ha = types.ModuleType("homeassistant")

tests_ha/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Home Assistant integration tests — CI only, Linux only. See conftest.py."""

tests_ha/conftest.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Fixtures for the Home Assistant integration tests.
2+
3+
These run only in CI, on Linux, where `pytest-homeassistant-custom-component` installs
4+
from prebuilt wheels. They cannot run on Windows: Home Assistant pins
5+
`lru-dict==1.3.0`, which has no CPython 3.13 Windows wheel and needs a C compiler.
6+
7+
Kept separate from `tests/` deliberately. That suite has three small dependencies and
8+
runs in half a second, which is what makes it usable on every register-map change.
9+
Merging the two would drag Home Assistant into every run and lose that.
10+
"""
11+
from __future__ import annotations
12+
13+
from unittest.mock import patch
14+
15+
import pytest
16+
from pytest_homeassistant_custom_component.common import MockConfigEntry
17+
18+
from custom_components.growatt_modbus.const import DOMAIN
19+
20+
21+
@pytest.fixture(autouse=True)
22+
def auto_enable_custom_integrations(enable_custom_integrations):
23+
"""Load `custom_components/` — without this HA ignores the integration entirely."""
24+
yield
25+
26+
27+
@pytest.fixture
28+
def mock_entry() -> MockConfigEntry:
29+
"""A TCP config entry resembling a real installation."""
30+
return MockConfigEntry(
31+
domain=DOMAIN,
32+
title="Growatt Test",
33+
data={
34+
"name": "Growatt Test",
35+
"connection_type": "tcp",
36+
"host": "192.0.2.10", # TEST-NET-1, guaranteed unroutable
37+
"port": 502,
38+
"slave_id": 1,
39+
"inverter_series": "min_7000_10000_tl_x",
40+
"register_map": "MIN_7000_10000TL_X",
41+
"vpp_protocol_confirmed": False,
42+
},
43+
options={
44+
"scan_interval": 60,
45+
"modbus_delay": 250,
46+
},
47+
)
48+
49+
50+
@pytest.fixture
51+
def bypass_connection():
52+
"""Stop the coordinator opening a socket.
53+
54+
The config and options flows are what these tests exercise; letting them attempt a
55+
real connection would make them slow and dependent on network behaviour.
56+
"""
57+
with patch(
58+
"custom_components.growatt_modbus.GrowattModbusCoordinator._fetch_data",
59+
return_value=None,
60+
):
61+
yield

tests_ha/test_options_flow.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
"""Options flow tests.
2+
3+
These exist because of a specific failure. The "Max Register Block Size" selector
4+
shipped in v1.2.0 declared as `vol.In({0: "Auto", 25: "25 registers", ...})` — keyed by
5+
integers, with `default=0`. **The option could never be saved.**
6+
7+
It survived four releases. The read path was wired correctly, so code inspection looked
8+
fine; the value simply never reached it. Two users found it independently, as two
9+
different symptoms — "nothing is selected" (#360) and "the option had zero effect"
10+
(#367) — and I initially told the second one the code was correct.
11+
12+
Nothing in the HA-free suite could have caught it: the defect is in a voluptuous schema
13+
that only misbehaves when Home Assistant renders and submits it. That is the entire
14+
argument for this directory existing.
15+
"""
16+
from __future__ import annotations
17+
18+
import pytest
19+
from homeassistant.core import HomeAssistant
20+
21+
from custom_components.growatt_modbus.const import BLOCK_SIZE_OPTIONS
22+
23+
24+
async def _open_options(hass: HomeAssistant, entry):
25+
entry.add_to_hass(hass)
26+
assert await hass.config_entries.async_setup(entry.entry_id)
27+
await hass.async_block_till_done()
28+
return await hass.config_entries.options.async_init(entry.entry_id)
29+
30+
31+
@pytest.mark.parametrize("label", list(BLOCK_SIZE_OPTIONS))
32+
async def test_every_block_size_label_can_be_saved(
33+
hass: HomeAssistant, mock_entry, bypass_connection, label
34+
):
35+
"""The regression, stated directly: each offered choice must persist.
36+
37+
Under the old schema this failed for every value — which is what made the option
38+
inert rather than merely awkward.
39+
"""
40+
result = await _open_options(hass, mock_entry)
41+
42+
result = await hass.config_entries.options.async_configure(
43+
result["flow_id"],
44+
user_input={
45+
"device_name": "Growatt Test",
46+
"inverter_series": "MIN (7-10kW)",
47+
"scan_interval": 60,
48+
"offline_scan_interval": 300,
49+
"invert_grid_power": False,
50+
"invert_battery_power": False,
51+
"battery_voltage_range": "Auto-detect",
52+
"modbus_delay": 250,
53+
"max_block_size": label,
54+
},
55+
)
56+
await hass.async_block_till_done()
57+
58+
assert result["type"] == "create_entry"
59+
assert mock_entry.options["max_block_size"] == label
60+
61+
62+
async def test_saved_block_size_reaches_the_read_path(
63+
hass: HomeAssistant, mock_entry, bypass_connection
64+
):
65+
"""Saving is only half of it — the value must arrive at the client.
66+
67+
v1.2.0 wired this end correctly while the form end was broken, so verifying only the
68+
wiring gave a false positive. This asserts the whole chain.
69+
"""
70+
from custom_components.growatt_modbus.const import resolve_block_size
71+
72+
result = await _open_options(hass, mock_entry)
73+
await hass.config_entries.options.async_configure(
74+
result["flow_id"],
75+
user_input={
76+
"device_name": "Growatt Test",
77+
"inverter_series": "MIN (7-10kW)",
78+
"scan_interval": 60,
79+
"offline_scan_interval": 300,
80+
"invert_grid_power": False,
81+
"invert_battery_power": False,
82+
"battery_voltage_range": "Auto-detect",
83+
"modbus_delay": 250,
84+
"max_block_size": "25 registers",
85+
},
86+
)
87+
await hass.async_block_till_done()
88+
89+
assert resolve_block_size(mock_entry.options["max_block_size"]) == 25
90+
91+
92+
async def test_options_form_opens_with_a_valid_default(
93+
hass: HomeAssistant, mock_entry, bypass_connection
94+
):
95+
"""A default that matches no offered choice renders as nothing selected.
96+
97+
That was the visible half of the bug (#360) — and because the field is Required,
98+
an unselected form also refuses to submit, blocking *every* option on the page.
99+
"""
100+
result = await _open_options(hass, mock_entry)
101+
assert result["type"] == "form"
102+
assert result["errors"] in (None, {})
103+
104+
105+
async def test_unrelated_option_can_be_changed_without_touching_block_size(
106+
hass: HomeAssistant, mock_entry, bypass_connection
107+
):
108+
"""The real user impact: a broken selector locked the whole form.
109+
110+
#360 could not change scan interval, because the invalid block-size default failed
111+
validation for the entire submission.
112+
"""
113+
result = await _open_options(hass, mock_entry)
114+
result = await hass.config_entries.options.async_configure(
115+
result["flow_id"],
116+
user_input={
117+
"device_name": "Growatt Test",
118+
"inverter_series": "MIN (7-10kW)",
119+
"scan_interval": 120,
120+
"offline_scan_interval": 300,
121+
"invert_grid_power": False,
122+
"invert_battery_power": False,
123+
"battery_voltage_range": "Auto-detect",
124+
"modbus_delay": 250,
125+
"max_block_size": "Auto (recommended)",
126+
},
127+
)
128+
await hass.async_block_till_done()
129+
130+
assert result["type"] == "create_entry"
131+
assert mock_entry.options["scan_interval"] == 120

tests_ha/test_setup.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Setup and teardown tests.
2+
3+
These cover what I have been asking a user to confirm by hand after every release —
4+
"does it load, does it reload" — which is the check that catches a broken `runtime_data`
5+
migration or entity base class before anyone installs it.
6+
7+
v1.3.1 and v1.3.2 both shipped as pre-releases purely because I could not answer that
8+
question locally.
9+
"""
10+
from __future__ import annotations
11+
12+
from homeassistant.config_entries import ConfigEntryState
13+
from homeassistant.core import HomeAssistant
14+
15+
from custom_components.growatt_modbus.const import DOMAIN
16+
17+
18+
async def test_entry_sets_up(hass: HomeAssistant, mock_entry, bypass_connection):
19+
mock_entry.add_to_hass(hass)
20+
assert await hass.config_entries.async_setup(mock_entry.entry_id)
21+
await hass.async_block_till_done()
22+
assert mock_entry.state is ConfigEntryState.LOADED
23+
24+
25+
async def test_coordinator_is_on_runtime_data(
26+
hass: HomeAssistant, mock_entry, bypass_connection
27+
):
28+
"""v1.3.1 moved the coordinator off `hass.data[DOMAIN]`.
29+
30+
`hass.data[DOMAIN]` should now hold only the cross-entry connection registry — the
31+
two used to be mixed, which is why code walking it needed a defensive check.
32+
"""
33+
mock_entry.add_to_hass(hass)
34+
assert await hass.config_entries.async_setup(mock_entry.entry_id)
35+
await hass.async_block_till_done()
36+
37+
assert getattr(mock_entry, "runtime_data", None) is not None
38+
assert mock_entry.entry_id not in hass.data.get(DOMAIN, {})
39+
40+
41+
async def test_entry_unloads(hass: HomeAssistant, mock_entry, bypass_connection):
42+
"""The unload path can only fail on unload — a load test cannot reach it."""
43+
mock_entry.add_to_hass(hass)
44+
assert await hass.config_entries.async_setup(mock_entry.entry_id)
45+
await hass.async_block_till_done()
46+
47+
assert await hass.config_entries.async_unload(mock_entry.entry_id)
48+
await hass.async_block_till_done()
49+
assert mock_entry.state is ConfigEntryState.NOT_LOADED
50+
51+
52+
async def test_entry_reloads(hass: HomeAssistant, mock_entry, bypass_connection):
53+
"""Reload is unload followed by setup, so it exercises both directions."""
54+
mock_entry.add_to_hass(hass)
55+
assert await hass.config_entries.async_setup(mock_entry.entry_id)
56+
await hass.async_block_till_done()
57+
58+
assert await hass.config_entries.async_reload(mock_entry.entry_id)
59+
await hass.async_block_till_done()
60+
assert mock_entry.state is ConfigEntryState.LOADED
61+
62+
63+
async def test_entities_are_created(hass: HomeAssistant, mock_entry, bypass_connection):
64+
"""Guards the GrowattEntity migration (v1.3.2).
65+
66+
A wrong base class shows up as entities silently not being created — the
67+
integration still loads, so a load check would pass.
68+
"""
69+
mock_entry.add_to_hass(hass)
70+
assert await hass.config_entries.async_setup(mock_entry.entry_id)
71+
await hass.async_block_till_done()
72+
73+
entities = [
74+
s for s in hass.states.async_all()
75+
if s.entity_id.startswith(("sensor.", "binary_sensor."))
76+
]
77+
assert entities, "no entities were created"
78+
79+
80+
async def test_diagnostics_can_be_produced(
81+
hass: HomeAssistant, mock_entry, bypass_connection
82+
):
83+
"""Diagnostics must never raise — it is needed when things are already broken."""
84+
from custom_components.growatt_modbus.diagnostics import (
85+
async_get_config_entry_diagnostics,
86+
)
87+
88+
mock_entry.add_to_hass(hass)
89+
assert await hass.config_entries.async_setup(mock_entry.entry_id)
90+
await hass.async_block_till_done()
91+
92+
diagnostics = await async_get_config_entry_diagnostics(hass, mock_entry)
93+
assert "entry" in diagnostics
94+
assert "coordinator" in diagnostics
95+
# Host is redacted — users paste these into public issues.
96+
assert diagnostics["entry"]["data"].get("host") != "192.0.2.10"

0 commit comments

Comments
 (0)