02 — Running Tests¶
The one rule: tests run in a Linux container¶
pytest-homeassistant-custom-component imports fcntl, a Unix-only stdlib
module. It cannot run on Windows Python — you get
ModuleNotFoundError: No module named 'fcntl'. Every test run goes through a
python:3.14-slim Docker container.
There is a runner for this:
It runs pytest in the pre-baked eufy-vacuum-test image (the HA test deps
baked in — see the locked-in image
below), mounting the repo at /workspace and passing through whatever arguments
you give it. No pip install per run.
@echo off
REM build the image once if it's missing, then reuse it
docker image inspect eufy-vacuum-test >nul 2>&1
if errorlevel 1 call "%~dp0build-test-image.bat" || exit /b 1
docker run --rm ^
-v "%~dp0..:/workspace" ^
-w /workspace ^
eufy-vacuum-test ^
python -m pytest %*
Examples¶
scripts\test.bat REM default testpaths (unit + integration)
scripts\test.bat tests/integration/test_config_flow.py -v
scripts\test.bat --no-cov -k test_setup_flow
scripts\test.bat tests/unit REM just the unit layer
scripts\test.bat tests/adapters REM the adapter suite (not in default testpaths)
scripts\test.bat tests/replay REM the recorder-replay harness (not in default testpaths)
scripts\test.bat tests --no-cov -p no:cacheprovider --tb=short REM the exact CI behavior gate
CI (.github/workflows/tests.yml) runs
python -m pytest tests --no-cov -p no:cacheprovider --tb=short, which collects
all four directories — tests/unit, tests/integration, tests/adapters,
and tests/replay — so the two paths outside the default testpaths are still
gated on every push.
Running it by hand¶
The suite needs Linux: pytest_homeassistant_custom_component pulls in
homeassistant.runner, which imports fcntl. On Linux or macOS, install
requirements_test.txt and run pytest directly. On Windows, run it in a
container.
Linux / macOS¶
Windows — and the two traps¶
- Use PowerShell, not Git Bash. Git Bash rewrites the
-w /workspaceargument (POSIX-path mangling turns/workspaceinto a Windows path), and the container fails to find the working directory. - Quote the volume mount, and give it an absolute path.
docker run --rm `
-v "${PWD}:/workspace" `
-w /workspace `
eufy-vacuum-test `
python -m pytest tests -q --no-header --no-cov
${PWD} resolves to the repository root when the command is run from there.
Substitute the absolute path if you invoke it from anywhere else.
(If you haven't built the image yet, run scripts\build-test-image.bat first,
or swap eufy-vacuum-test for python:3.14-slim and prefix the command with
pip install -q -r requirements_test.txt && for a one-off zero-setup run.)
Common flag recipes¶
| Goal | Flags |
|---|---|
| Fast iteration on one file, no coverage | tests/integration/test_x.py -q --no-header --no-cov |
| Run one test by name | -k test_name_substring |
| Stop at first failure | -x |
| Short tracebacks | --tb=short |
| Coverage for specific modules only | --cov=custom_components/eufy_vacuum/learning/manager --cov-report=term-missing |
| Disable pytest cache (clean container) | -p no:cacheprovider |
--no-cov is worth using during iteration — coverage instrumentation slows the
run and clutters the output. The default addopts always turns coverage on, so
pass --no-cov to suppress it.
Reading coverage¶
The default run prints a term-missing table (uncovered line numbers per file)
only — no HTML report is written. The addopts in pytest.ini enable just
--cov-report=term-missing and --cov-branch. To get an HTML report, ask for one
explicitly: pass --cov-report=html:htmlcov_scratch for a disposable report, or
--cov-report=html:htmlcov for the canonical regen. (Keeping htmlcov/
explicit-only means an incidental or sub-agent coverage run can never clobber it.)
Both htmlcov/ and the .coverage data file are gitignored.
Refreshing the numbers in these docs¶
The coverage percentages and test counts scattered through the testing docs (per-module tables, the per-subsystem Cov column, the headline figures) are maintained by a script so they don't drift. Run it inside the container:
docker run --rm -v "<repo>:/workspace" -w /workspace eufy-vacuum-test \
python scripts/update_test_docs.py
It runs coverage, then rewrites only the numeric fields in place — prose, module
lists, and gap notes are never touched. Add --dry-run to preview, or --no-run
to reuse an existing coverage.json. Review the diff before committing.
Per-file vs combined coverage¶
A module's coverage number depends on which test files you run. Example:
the pure-function helpers at the top of learning/job_finalizer.py are covered
by tests/unit/test_learning_job_finalizer.py. If you run only the integration
file, those lines show as missed and the percentage drops. To get the true
number for a module, run all files that exercise it:
scripts\test.bat tests/integration/test_learning_services.py tests/unit/test_learning_job_finalizer.py ^
--cov=custom_components/eufy_vacuum/learning/job_finalizer --cov-report=term-missing
The image-processing stack (numpy / scipy / Pillow)¶
The mapping segmentor pipeline depends on numpy, scipy, and Pillow:
- numpy arrives transitively through Home Assistant core, so it is always present.
- scipy and Pillow are explicit entries in
requirements_test.txt— they are not pulled in by HA core. Without them the scipy/Pillow-dependent mapping code (segment_primitivestransforms,mask_edge_band,estimate_alignment, theeufy_cvsegmentor) can't be exercised.
Even so, scipy-only code paths should guard with
pytest.importorskip("scipy.ndimage") so a test file degrades to a skip rather
than an error if the stack is ever stripped from the environment:
The locked-in image (no pip install per run)¶
Installing requirements_test.txt fresh in the container costs ~1-2 min — far
longer than the suite itself (well under a minute). So the deps are baked into
a local image once and reused:
| File | Role |
|---|---|
scripts/test.Dockerfile |
python:3.14-slim + pip install -r requirements_test.txt |
scripts/build-test-image.bat |
builds/rebuilds the eufy-vacuum-test image |
scripts/test.bat |
runs pytest in that image (auto-builds it on first run) |
Lifecycle:
- First run —
scripts\test.batsees noeufy-vacuum-testimage and builds it once (the only slow run). - Every run after — reuses the image, so a test run is just
pytestwith no install step. - After
requirements_test.txtchanges — rebuild withscripts\build-test-image.bat(or delete the image and lettest.batrebuild it).
The image is ~1.4 GB and lives only on your machine — it is not committed; only
the Dockerfile + scripts are. Anyone who clones the repo gets the same baked
environment from the first scripts\test.bat.
Iterating tightly? Run a single file (
scripts\test.bat tests/unit/test_x.py --no-cov) — the image is already warm, so the only cost is the test itself.