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 whole suite (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)
Running it by hand (and the two Windows traps)¶
If you invoke Docker yourself instead of using test.bat, two things bite on
this machine:
- 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 dir. Run Docker from PowerShell. - Quote the volume mount with the absolute Windows path.
A known-good PowerShell invocation (against the pre-baked image, no pip install):
docker run --rm `
-v "C:\Users\CKing\Documents\GITHUB\eufy-vacuum-manager:/workspace" `
-w /workspace `
eufy-vacuum-test `
python -m pytest tests/ -q --no-header
(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.