Testing
ApexOS core ships with an extensive pytest suite. Every integration change is expected to come with tests, and CI will run them for you — but running them locally first is much faster.
Layout
Tests live under tests/ (the configured testpaths in pyproject.toml) and mirror the source tree:
tests/
├── conftest.py # global fixtures (the apex fixture family lives here)
├── common.py # shared helpers: MockConfigEntry, restore-state mocks, ...
├── components/
│ └── <domain>/ # one directory per integration
│ ├── __init__.py # required — CI fails if it's missing
│ ├── conftest.py # integration-local fixtures
│ ├── test_init.py
│ ├── test_sensor.py, test_config_flow.py, ...
│ └── snapshots/ # syrupy snapshot files (*.ambr)
├── fixtures/ # static payload files shared across tests
├── auth/ helpers/ ... # tests for the non-component core
Test files map one-to-one onto the platform modules they cover (sensor.py → test_sensor.py, and so on).
pytest runs in asyncio auto mode (asyncio_mode = "auto" in pyproject.toml), so async test functions need no decorator — just declare them async def. asyncio_debug is enabled, and warnings configured in filterwarnings are errors unless explicitly ignored.
The apex fixture family
The central fixture is apex — it yields a fully wired test instance of the platform (type ApexOS, created via async_test_apexos). Exceptions raised inside the event loop are captured and fail the test, and lingering tasks or timers after teardown fail it too.
from apexos.core import ApexOS
from apexos.setup import async_setup_component
async def test_my_sensor(apex: ApexOS) -> None:
"""Test the sensor sets its state."""
assert await async_setup_component(apex, "sensor", {...})
await apex.async_block_till_done()
state = apex.states.get("sensor.my_sensor")
assert state is not None
Other fixtures you will reach for, all defined in tests/conftest.py:
| Fixture | Purpose |
|---|---|
apex | The test platform instance. |
apex_client | Authenticated aiohttp test client against the instance's HTTP API. |
apex_client_no_auth | Same, without authentication. |
apex_ws_client | WebSocket API test client. |
apex_access_token, apex_admin_credential | Auth tokens/credentials for API tests. |
apex_owner_user, apex_admin_user, apex_read_only_user, apex_supervisor_user | Pre-built users with different permission levels. |
apex_storage | Dict backing the mocked .storage layer — pre-seed or inspect stored data. |
apex_config_dir, apex_tmp_config_dir | Override the test config directory (e.g. with pre-populated files). |
aioclient_mock | AiohttpClientMocker for outbound HTTP calls. |
mqtt_mock, mqtt_client_mock | Mocked MQTT broker plumbing. |
mock_network, mock_get_source_ip | Deterministic network environment. |
tests/common.py adds helpers such as MockConfigEntry for setting up config entries without a real flow.
Running tests
Single integration (the common case):
python3 -m pytest tests/components/<domain>
A single file or test:
python3 -m pytest tests/components/<domain>/test_sensor.py -k test_name
What CI runs
The per-integration CI job (pytest-partial) invokes:
python3 -b -X dev -m pytest \
-qq \
--timeout=9 \
--numprocesses auto \
--snapshot-details \
--cov="apexos.components.<domain>" \
--cov-report=xml \
--cov-report=term-missing \
-o console_output_style=count \
--durations=0 \
--durations-min=1 \
-p no:sugar \
--exclude-warning-annotations \
tests/components/<domain>
The full-suite job (pytest-full) shards the tree into buckets and adds --dist=loadfile so files stay on one worker. Points to note if you want CI parity locally:
--numprocesses auto(pytest-xdist) parallelizes across cores — the suite assumes tests are parallel-safe.--timeout=9— individual tests must finish in under nine seconds; a slow-test problem matcher flags offenders.--snapshot-details— syrupy snapshot assertions print full diffs. Regenerate snapshots locally with--snapshot-updatewhen an intentional change alters them.python3 -b -X dev— dev mode plus warnings on bytes/str misuse.- Dedicated jobs re-run the recorder tests against MariaDB and PostgreSQL.
Test dependencies are pinned in requirements_test.txt (which also pulls in requirements_test_pre_commit.txt); both are installed by script/bootstrap.
Linting: Ruff and prek hooks
Lint is enforced through prek, the pre-commit hook runner (pinned in requirements_test.txt; script/setup installs the git hook via prek install). The hooks in .pre-commit-config.yaml:
- ruff-check (with
--fix) and ruff-format — the primary linter and formatter, minimum version pinned inpyproject.toml(required-version = ">=0.15.19"). - codespell — spell-checks code and docs.
- yamllint and prettier — YAML and JSON/markdown formatting.
- zizmor (
--pedantic) — GitHub workflow security lint. - Standard hygiene hooks — JSON validity, no commits to protected branches.
- Local hooks for mypy and pylint, run through
script/run-in-env.shso the repo virtualenv is used even when committing from a GUI.
Run everything against your changes on demand:
prek run --all-files # everything
prek run ruff-check --files apexos/components/<domain>/*.py
script/lint # ruff + pylint on your branch's changed files
CI runs the same hooks in the prek job, plus separate mypy, pylint, and apexfest (manifest/generated-file validation) jobs — so if prek and pytest pass locally, you're most of the way to a green build.