Skip to main content

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.pytest_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:

FixturePurpose
apexThe test platform instance.
apex_clientAuthenticated aiohttp test client against the instance's HTTP API.
apex_client_no_authSame, without authentication.
apex_ws_clientWebSocket API test client.
apex_access_token, apex_admin_credentialAuth tokens/credentials for API tests.
apex_owner_user, apex_admin_user, apex_read_only_user, apex_supervisor_userPre-built users with different permission levels.
apex_storageDict backing the mocked .storage layer — pre-seed or inspect stored data.
apex_config_dir, apex_tmp_config_dirOverride the test config directory (e.g. with pre-populated files).
aioclient_mockAiohttpClientMocker for outbound HTTP calls.
mqtt_mock, mqtt_client_mockMocked MQTT broker plumbing.
mock_network, mock_get_source_ipDeterministic 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-update when 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 in pyproject.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.sh so 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.