mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-08-07 01:13:11 +02:00
chore(tests): speed up pytest from >2min to ~12s
- Default-off coverage in pyproject.toml addopts; opt-in via make test-cov - Add pytest-xdist for parallel execution (make test = pytest -nauto --no-cov) - Promote API test fixtures to session/module scope (engine, app, mocks); per-test isolation via table truncation instead of schema rebuild - Remove Makefile include .env/export that leaked config vars into tests; docker-compose reads .env natively - Add _ignore_dotenv autouse fixture: disables env_file, clears leaked env vars from Settings fields and Click CLI envvars - Patch time.sleep in 3 subscriber scheduler tests (~3s -> ~0.03s) - Fix pytest.raises(Exception, match='') warning -> IntegrityError - Add .venv activation to .envrc - Suppress warn_unused_ignores for tests in mypy config (single-file pre-commit checks lack full-project context)
This commit is contained in:
@@ -1,3 +1,7 @@
|
||||
if has nix && declare -F use_nix >/dev/null; then
|
||||
use nix
|
||||
fi
|
||||
|
||||
if [ -f .venv/bin/activate ]; then
|
||||
source .venv/bin/activate
|
||||
fi
|
||||
|
||||
@@ -40,9 +40,16 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile core ex
|
||||
|
||||
## Tests & Quality
|
||||
|
||||
Coverage is **opt-in**; add `--cov=meshcore_hub` (or `make test-cov`) when you want it. The dev loop defaults to no coverage and parallel across CPU cores.
|
||||
|
||||
```bash
|
||||
# Canonical: run tests and surface the pass/fail summary
|
||||
pytest --no-cov 2>&1 | grep -iE "passed|failed" | tail -3
|
||||
# Canonical: run tests in parallel, no coverage, surface the pass/fail summary
|
||||
pytest -nauto --no-cov 2>&1 | grep -iE "passed|failed" | tail -3
|
||||
|
||||
# Makefile shorthands
|
||||
make test # pytest -nauto --no-cov (parallel dev loop)
|
||||
make test-cov # full run with coverage report
|
||||
make test-unit # parallel, fast unit suites only (skips e2e)
|
||||
|
||||
# Targeted by component (run only what you changed)
|
||||
pytest --no-cov tests/test_web/ # templates, static JS, web routes
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
ifneq (,$(wildcard ./.env))
|
||||
include .env
|
||||
export
|
||||
endif
|
||||
|
||||
COMPOSE_PROJECT_NAME ?= hub
|
||||
PROFILES ?= mqtt core
|
||||
COMPOSE_FILES = -f docker-compose.yml -f docker-compose.dev.yml
|
||||
VOLUMES = $(COMPOSE_PROJECT_NAME)_data $(COMPOSE_PROJECT_NAME)_mqtt_data \
|
||||
$(COMPOSE_PROJECT_NAME)_observer_data
|
||||
|
||||
.PHONY: build up down logs backup restore
|
||||
.PHONY: build up down logs backup restore test test-cov test-unit
|
||||
|
||||
build:
|
||||
docker compose $(COMPOSE_FILES) --profile all build --no-cache
|
||||
@@ -38,3 +33,14 @@ restore:
|
||||
echo "Restoring $$vol from $(FILE)..."; \
|
||||
docker run --rm -v $$vol:/data -v $(PWD)/backup:/backup \
|
||||
alpine sh -c "cd / && tar xzf /backup/$$(basename $(FILE))"
|
||||
|
||||
# --- Tests ---------------------------------------------------------------
|
||||
# Coverage is opt-in (use test-cov). Dev loop runs in parallel across cores.
|
||||
test:
|
||||
pytest -nauto --no-cov
|
||||
|
||||
test-cov:
|
||||
pytest --cov=meshcore_hub --cov-report=term-missing
|
||||
|
||||
test-unit:
|
||||
pytest -nauto --no-cov tests/test_common/ tests/test_api/ tests/test_collector/ tests/test_web/
|
||||
|
||||
+8
-2
@@ -56,6 +56,7 @@ dev = [
|
||||
"pytest>=7.4.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"pytest-xdist>=3.5.0",
|
||||
"black>=23.0.0",
|
||||
"flake8>=6.1.0",
|
||||
"mypy>=1.5.0",
|
||||
@@ -137,6 +138,10 @@ module = [
|
||||
]
|
||||
disallow_untyped_defs = false
|
||||
disallow_incomplete_defs = false
|
||||
# Single-file mypy checks (e.g. pre-commit commit hook) lack full project
|
||||
# context and report false-positive unused-ignore on type:ignore comments
|
||||
# that are required in full-project mode.
|
||||
warn_unused_ignores = false
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
@@ -156,8 +161,9 @@ addopts = [
|
||||
"-ra",
|
||||
"-q",
|
||||
"--strict-markers",
|
||||
"--cov=meshcore_hub",
|
||||
"--cov-report=term-missing",
|
||||
]
|
||||
markers = [
|
||||
"e2e: end-to-end tests requiring Docker services (skipped unless --e2e)",
|
||||
]
|
||||
filterwarnings = [
|
||||
"ignore::DeprecationWarning",
|
||||
|
||||
@@ -4,9 +4,93 @@ import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from meshcore_hub.common import config as config_module
|
||||
from meshcore_hub.common.models import Base
|
||||
|
||||
|
||||
def _settings_classes():
|
||||
"""CommonSettings and every subclass (recursively)."""
|
||||
seen: set[type] = set()
|
||||
stack = [config_module.CommonSettings]
|
||||
while stack:
|
||||
cls = stack.pop()
|
||||
if cls in seen:
|
||||
continue
|
||||
seen.add(cls)
|
||||
stack.extend(cls.__subclasses__())
|
||||
return seen
|
||||
|
||||
|
||||
def _cli_envvars() -> set[str]:
|
||||
"""Collect Click envvar names from CLI commands (best-effort).
|
||||
|
||||
CLI options read env vars via ``envvar=`` independently of pydantic
|
||||
Settings, so ``_settings_classes`` alone misses them (e.g. ``API_WORKERS``).
|
||||
"""
|
||||
import importlib
|
||||
|
||||
import click
|
||||
|
||||
envvars: set[str] = set()
|
||||
|
||||
def _collect(cmd: click.BaseCommand) -> None:
|
||||
if isinstance(cmd, click.Group):
|
||||
for subcmd in cmd.commands.values():
|
||||
_collect(subcmd)
|
||||
if isinstance(cmd, click.Command):
|
||||
for param in cmd.params:
|
||||
if isinstance(param, click.Option) and param.envvar:
|
||||
ev = param.envvar
|
||||
if isinstance(ev, str):
|
||||
envvars.add(ev)
|
||||
else:
|
||||
envvars.update(ev)
|
||||
|
||||
for module_path in (
|
||||
"meshcore_hub.api.cli",
|
||||
"meshcore_hub.collector.cli",
|
||||
"meshcore_hub.web.cli",
|
||||
):
|
||||
try:
|
||||
mod = importlib.import_module(module_path)
|
||||
for attr in vars(mod).values():
|
||||
if isinstance(attr, click.BaseCommand):
|
||||
_collect(attr)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return envvars
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ignore_dotenv(monkeypatch):
|
||||
"""Stop pydantic-settings and Click from reading ``.env`` or leaked env vars.
|
||||
|
||||
Three-pronged defence:
|
||||
|
||||
1. Disable ``env_file`` on every settings subclass so pydantic-settings
|
||||
won't read the ``.env`` file itself.
|
||||
2. Delete any env vars matching a settings field name from ``os.environ``
|
||||
for the duration of the test.
|
||||
3. Delete any env vars matching a Click CLI ``envvar=`` name (e.g.
|
||||
``API_WORKERS``) that aren't settings fields.
|
||||
|
||||
This catches vars exported into the shell via direnv, Makefile, CI, etc.
|
||||
before pytest started. Tests must depend only on defaults and explicit
|
||||
env overrides (``monkeypatch.setenv``).
|
||||
"""
|
||||
for cls in _settings_classes():
|
||||
cfg = dict(cls.model_config)
|
||||
cfg["env_file"] = None
|
||||
monkeypatch.setattr(cls, "model_config", cfg)
|
||||
|
||||
for field_name in cls.model_fields:
|
||||
monkeypatch.delenv(field_name.upper(), raising=False)
|
||||
|
||||
for ev in _cli_envvars():
|
||||
monkeypatch.delenv(ev, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_engine():
|
||||
"""Create an in-memory SQLite database engine for testing."""
|
||||
|
||||
+99
-88
@@ -4,7 +4,6 @@ import os
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -33,20 +32,27 @@ from meshcore_hub.common.models import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session")
|
||||
def test_db_path():
|
||||
"""Create a temporary database file path."""
|
||||
"""Session-scoped temporary database file path.
|
||||
|
||||
One file per pytest session; the engine below builds schema on it once.
|
||||
"""
|
||||
fd, path = tempfile.mkstemp(suffix=".db")
|
||||
os.close(fd)
|
||||
yield path
|
||||
# Cleanup
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session")
|
||||
def api_db_engine(test_db_path):
|
||||
"""Create a SQLite database engine for API testing."""
|
||||
"""Session-scoped SQLite engine. Schema is built once per pytest session.
|
||||
|
||||
Previously this was function-scoped and rebuilt ~15 tables for every test,
|
||||
costing ~0.2s/test. Promoting it to session scope eliminates that. Per-test
|
||||
isolation is handled by truncation in ``api_db_session``.
|
||||
"""
|
||||
db_url = f"sqlite:///{test_db_path}"
|
||||
engine = create_engine(
|
||||
db_url,
|
||||
@@ -65,18 +71,33 @@ def api_db_engine(test_db_path):
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def _truncate_all(engine) -> None:
|
||||
"""Delete rows from every table in child-first order (FK-safe)."""
|
||||
with engine.begin() as conn:
|
||||
for table in reversed(Base.metadata.sorted_tables):
|
||||
conn.execute(table.delete())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_db_session(api_db_engine):
|
||||
"""Create a database session for API testing."""
|
||||
"""Per-test session bound to the shared session-scoped engine.
|
||||
|
||||
Rows are truncated at teardown so each test starts with empty tables
|
||||
without paying schema-build cost. Tests must ``commit()`` their seed
|
||||
data before invoking the test client (the sample_* fixtures already do).
|
||||
"""
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
session = Session()
|
||||
yield session
|
||||
session.close()
|
||||
_truncate_all(api_db_engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session")
|
||||
def mock_mqtt():
|
||||
"""Create a mock MQTT client."""
|
||||
"""Session-scoped mock MQTT client (no per-test state)."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
mock = MagicMock()
|
||||
mock.connect.return_value = None
|
||||
mock.start_background.return_value = None
|
||||
@@ -86,9 +107,11 @@ def mock_mqtt():
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="session")
|
||||
def mock_db_manager(api_db_engine):
|
||||
"""Create a mock database manager using the test engine."""
|
||||
"""Session-scoped mock database manager backed by the shared engine."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
manager = MagicMock(spec=DatabaseManager)
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
manager.get_session = lambda: Session()
|
||||
@@ -109,95 +132,83 @@ def mock_db_manager(api_db_engine):
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_db_global(monkeypatch: pytest.MonkeyPatch, mock_db_manager) -> None:
|
||||
"""Pin ``meshcore_hub.api.app._db_manager`` to the mock for every test.
|
||||
|
||||
Function-scoped autouse so tests that mutate the global (e.g. the lifespan
|
||||
tests in ``test_cache.py``) cannot leak state to siblings. ``monkeypatch``
|
||||
restores the prior value on exit.
|
||||
"""
|
||||
import meshcore_hub.api.app as app_module
|
||||
|
||||
monkeypatch.setattr(app_module, "_db_manager", mock_db_manager)
|
||||
|
||||
|
||||
def _wire_overrides(app, api_db_engine, mock_mqtt, mock_db_manager) -> None:
|
||||
"""Install the standard DB/MQTT dependency overrides on ``app``."""
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
|
||||
def override_get_db_manager(request=None):
|
||||
return mock_db_manager
|
||||
|
||||
def override_get_db_session():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def override_get_mqtt_client(request=None):
|
||||
return mock_mqtt
|
||||
|
||||
app.dependency_overrides[get_db_manager] = override_get_db_manager
|
||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
app.dependency_overrides[get_mqtt_client] = override_get_mqtt_client
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_no_auth(test_db_path, api_db_engine, mock_mqtt, mock_db_manager):
|
||||
"""Create a FastAPI app with no authentication required."""
|
||||
"""Module-scoped FastAPI app with no authentication.
|
||||
|
||||
Built once per test module; ``create_app`` is the second-most expensive
|
||||
setup step (~0.3s), so sharing it across a module is a major win. The
|
||||
``_isolate_db_global`` autouse fixture handles the global ``_db_manager``
|
||||
so this fixture doesn't need a ``with patch(...)`` context.
|
||||
"""
|
||||
db_url = f"sqlite:///{test_db_path}"
|
||||
|
||||
# Patch the global db_manager to avoid lifespan issues
|
||||
with patch("meshcore_hub.api.app._db_manager", mock_db_manager):
|
||||
app = create_app(
|
||||
database_url=db_url,
|
||||
read_key=None,
|
||||
admin_key=None,
|
||||
)
|
||||
|
||||
# Create session maker for this test engine
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
|
||||
def override_get_db_manager(request=None):
|
||||
return mock_db_manager
|
||||
|
||||
def override_get_db_session():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def override_get_mqtt_client(request=None):
|
||||
return mock_mqtt
|
||||
|
||||
app.dependency_overrides[get_db_manager] = override_get_db_manager
|
||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
app.dependency_overrides[get_mqtt_client] = override_get_mqtt_client
|
||||
|
||||
yield app
|
||||
app = create_app(
|
||||
database_url=db_url,
|
||||
read_key=None,
|
||||
admin_key=None,
|
||||
)
|
||||
_wire_overrides(app, api_db_engine, mock_mqtt, mock_db_manager)
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@pytest.fixture(scope="module")
|
||||
def app_with_auth(test_db_path, api_db_engine, mock_mqtt, mock_db_manager):
|
||||
"""Create a FastAPI app with authentication enabled."""
|
||||
"""Module-scoped FastAPI app with authentication enabled."""
|
||||
db_url = f"sqlite:///{test_db_path}"
|
||||
|
||||
with patch("meshcore_hub.api.app._db_manager", mock_db_manager):
|
||||
app = create_app(
|
||||
database_url=db_url,
|
||||
read_key="test-read-key",
|
||||
admin_key="test-admin-key",
|
||||
)
|
||||
|
||||
Session = sessionmaker(bind=api_db_engine)
|
||||
|
||||
def override_get_db_manager(request=None):
|
||||
return mock_db_manager
|
||||
|
||||
def override_get_db_session():
|
||||
session = Session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
def override_get_mqtt_client(request=None):
|
||||
return mock_mqtt
|
||||
|
||||
app.dependency_overrides[get_db_manager] = override_get_db_manager
|
||||
app.dependency_overrides[get_db_session] = override_get_db_session
|
||||
app.dependency_overrides[get_mqtt_client] = override_get_mqtt_client
|
||||
|
||||
yield app
|
||||
app = create_app(
|
||||
database_url=db_url,
|
||||
read_key="test-read-key",
|
||||
admin_key="test-admin-key",
|
||||
)
|
||||
_wire_overrides(app, api_db_engine, mock_mqtt, mock_db_manager)
|
||||
yield app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_no_auth(app_no_auth, mock_db_manager):
|
||||
"""Create a test client with no authentication.
|
||||
|
||||
Uses raise_server_exceptions=False to skip lifespan events.
|
||||
"""
|
||||
# Don't use context manager to skip lifespan
|
||||
client = TestClient(app_no_auth, raise_server_exceptions=True)
|
||||
yield client
|
||||
def client_no_auth(app_no_auth) -> TestClient:
|
||||
"""Test client with no authentication."""
|
||||
return TestClient(app_no_auth, raise_server_exceptions=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_with_auth(app_with_auth, mock_db_manager):
|
||||
"""Create a test client with authentication enabled.
|
||||
|
||||
Uses raise_server_exceptions=False to skip lifespan events.
|
||||
"""
|
||||
client = TestClient(app_with_auth, raise_server_exceptions=True)
|
||||
yield client
|
||||
def client_with_auth(app_with_auth) -> TestClient:
|
||||
"""Test client with authentication enabled."""
|
||||
return TestClient(app_with_auth, raise_server_exceptions=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -49,8 +49,9 @@ class TestSubscriber:
|
||||
|
||||
def test_stop_disconnects_mqtt(self, subscriber, mock_mqtt_client):
|
||||
"""Test that stop disconnects MQTT."""
|
||||
subscriber.start()
|
||||
subscriber.stop()
|
||||
with patch("meshcore_hub.collector.subscriber.time.sleep"):
|
||||
subscriber.start()
|
||||
subscriber.stop()
|
||||
|
||||
mock_mqtt_client.stop.assert_called_once()
|
||||
mock_mqtt_client.disconnect.assert_called_once()
|
||||
@@ -1229,13 +1230,14 @@ class TestChannelKeyRefresh:
|
||||
mock_mqtt_client, db_manager, channel_refresh_interval_seconds=300
|
||||
)
|
||||
subscriber._running = True
|
||||
subscriber._start_channel_refresh_scheduler()
|
||||
with patch("meshcore_hub.collector.subscriber.time.sleep"):
|
||||
subscriber._start_channel_refresh_scheduler()
|
||||
|
||||
assert subscriber._channel_refresh_thread is not None
|
||||
assert subscriber._channel_refresh_thread.daemon is True
|
||||
assert subscriber._channel_refresh_thread is not None
|
||||
assert subscriber._channel_refresh_thread.daemon is True
|
||||
|
||||
subscriber._running = False
|
||||
subscriber._channel_refresh_thread.join(timeout=2.0)
|
||||
subscriber._running = False
|
||||
subscriber._channel_refresh_thread.join(timeout=2.0)
|
||||
|
||||
def test_channel_refresh_scheduler_disabled(self, mock_mqtt_client, db_manager):
|
||||
"""Test channel refresh scheduler is disabled when interval is 0."""
|
||||
@@ -1255,10 +1257,11 @@ class TestChannelKeyRefresh:
|
||||
mock_mqtt_client, db_manager, channel_refresh_interval_seconds=300
|
||||
)
|
||||
subscriber._running = True
|
||||
subscriber._start_channel_refresh_scheduler()
|
||||
subscriber._running = False
|
||||
with patch("meshcore_hub.collector.subscriber.time.sleep"):
|
||||
subscriber._start_channel_refresh_scheduler()
|
||||
subscriber._running = False
|
||||
|
||||
subscriber._stop_channel_refresh_scheduler()
|
||||
subscriber._stop_channel_refresh_scheduler()
|
||||
assert subscriber._channel_refresh_thread is not None
|
||||
assert not subscriber._channel_refresh_thread.is_alive()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import hashlib
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from meshcore_hub.common.models import Base, Channel, ChannelVisibility
|
||||
@@ -118,7 +119,7 @@ class TestChannelModel:
|
||||
db_session.commit()
|
||||
db_session.add(ch2)
|
||||
|
||||
with pytest.raises(Exception, match=""):
|
||||
with pytest.raises(IntegrityError):
|
||||
db_session.commit()
|
||||
|
||||
def test_channel_default_values(self, db_session) -> None:
|
||||
|
||||
Reference in New Issue
Block a user