diff --git a/.gitignore b/.gitignore index af1ea90..d1e16df 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ venv/ .coverage coverage.xml htmlcov/ +test-results/ dist/ build/ *.egg-info/ diff --git a/Makefile b/Makefile index aa29df8..81c3e6d 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ BACKEND := apps/backend .DEFAULT_GOAL := help -.PHONY: help install dev lint format typecheck test test-integration check docker-build \ - db-up db-down db-reset db-logs db-psql migrate +.PHONY: help install dev lint format typecheck test test-cov test-integration check \ + docker-build db-up db-down db-reset db-logs db-psql migrate help: ## Liste les cibles disponibles @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}' @@ -23,7 +23,11 @@ typecheck: ## Verifie le typage du backend cd $(BACKEND) && uv run mypy app test: ## Execute les tests backend ne demandant pas de base - cd $(BACKEND) && uv run pytest + cd $(BACKEND) && uv run pytest --cov-fail-under=85 + +test-cov: ## Rapports de couverture HTML et XML, plus les resultats au format JUnit + cd $(BACKEND) && uv run pytest --cov-fail-under=85 --cov-report=html \ + --cov-report=xml --junitxml=test-results/junit.xml test-integration: ## Execute les tests exigeant une base joignable cd $(BACKEND) && uv run pytest -m integration diff --git a/README.md b/README.md index 12342ac..7e2e4ae 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,6 @@ curl -s localhost:8000/api/v1/health/ready ## Conventions -- Branches : `feat/`, `fix/`, `chore/`, `docs/` suivi d'un libelle court. +- Branches : `feat/`, `fix/`, `chore/`, `docs/`, `test/` suivi d'un libelle court. - Commits : Conventional Commits, portee = dossier de premier niveau concerne. - Toute decision structurante donne lieu a un ADR dans `docs/adr`. diff --git a/apps/backend/README.md b/apps/backend/README.md index d70b3ca..5d940b8 100644 --- a/apps/backend/README.md +++ b/apps/backend/README.md @@ -41,6 +41,9 @@ uv run pytest # tests + couverture uv run pytest -m integration # tests exigeant une base joignable ``` +Les conventions de tests, les gabarits et le detail des marqueurs sont dans +[`TESTING.md`](TESTING.md). + `pytest` ecarte par defaut les tests marques `integration`, pour que `make check` reste jouable sans Docker. Ces tests visent la base `enervision_test`, creee par `db/init/110-test-database.sql` au premier demarrage du conteneur. diff --git a/apps/backend/TESTING.md b/apps/backend/TESTING.md new file mode 100644 index 0000000..f794421 --- /dev/null +++ b/apps/backend/TESTING.md @@ -0,0 +1,143 @@ +# Conventions de tests unitaires : Backend + +## Outil + +pytest, avec pytest-asyncio en mode `auto` : un `async def test_*` est collecte sans +decorateur. Les appels HTTP passent par httpx sur `ASGITransport`, qui parle a +l'application en memoire, sans serveur ni port ouvert. + +## Ou ecrire les tests + +`tests/` est le miroir de `app/` : un test de `app/services/consumption.py` va dans +`tests/services/test_consumption.py`. Les paquets `core`, `db`, `services` et +`repositories` existent deja, vides, pour cette raison. + +## Nommage + +- Fonctions en anglais : `test___when_`. +- `ids=` de `parametrize` en francais : `ids=["erreur_sqlalchemy", "erreur_reseau"]`. +- Pas de docstring : le nom porte l'intention. + +## Structure attendue (Arrange / Act / Assert) + +Une ligne vide separe les trois temps, sans commentaire pour les annoncer. + +```python +async def test_readiness_returns_503_when_the_extension_is_missing( + fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=None) + + response = await client.get("/api/v1/health/ready") + + assert response.status_code == 503 + assert response.json()["detail"] == "Extension TimescaleDB absente" +``` + +## Ce qui doit etre teste en priorite + +Le sens de dependance du backend est `endpoints -> services -> repositories -> models`. + +| Couche | Ce qu'on teste | +|---|---| +| `services/` | La logique metier, cas nominal et cas d'erreur. C'est la priorite. | +| `repositories/` | Chaque branche de decision, sous le marqueur `integration`. | +| `endpoints/` | Le code de statut et la forme de la reponse, pas la logique metier. | +| `schemas/` | Rien, sauf si le schema porte une validation ecrite a la main. | + +## Doubles + +On remplace une dependance FastAPI par `app.dependency_overrides`, jamais par +`unittest.mock`. `tests/factories.py` fournit le necessaire. + +- `fake_session(result=...)` : la session repond `result`. +- `fake_session(failure=...)` : la session leve l'exception. +- `make_settings(**overrides)` : fabrique une `Settings`, dont les valeurs priment sur + l'environnement et sur `.env`. C'est le moyen de tester `create_app` en `prod`. + +## Gabarit : un endpoint + +```python +from collections.abc import Callable + +from httpx import AsyncClient + + +async def test_endpoint_returns_the_expected_payload( + fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result=42) + + response = await client.get("/api/v1/...") + + assert response.status_code == 200 + assert response.json() == {"valeur": 42} +``` + +## Gabarit : un service avec repository factice + +Un service ne connait que son repository : on lui en passe un faux, sans base ni session. + +```python +from app.services.consumption import ConsumptionService + + +class FakeRepository: + async def total_for(self, site_id: int) -> float: + return 12.5 + + +async def test_service_converts_the_total_to_kilowatt_hours() -> None: + service = ConsumptionService(FakeRepository()) + + total = await service.total_kwh(site_id=1) + + assert total == 12.5 +``` + +## Gabarit : un repository sur la vraie base + +Un repository parle du SQL : le tester sur un double ne prouve rien. Il porte donc le +marqueur `integration`, ecarte par defaut. + +```python +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.site import Site +from app.repositories.site import SiteRepository + + +@pytest.mark.integration +async def test_repository_reads_back_what_it_wrote(session: AsyncSession) -> None: + repository = SiteRepository(session) + + await repository.add(Site(name="Toulouse")) + + assert await repository.by_name("Toulouse") is not None +``` + +## Marqueurs + +`integration` designe tout test exigeant une base joignable. `pytest` les ecarte par +defaut, ce qui garde `make check` jouable sans Docker. Tout autre marqueur doit etre +declare dans `pyproject.toml` : `--strict-markers` refuse les marqueurs inconnus. + +## Couverture + +Les branches sont mesurees, pas seulement les lignes. Le seuil de 85 % ne s'applique +qu'aux cibles qui jouent toute la suite, `make test` et `make test-cov` : un fichier +joue seul affiche sa couverture sans jamais echouer dessus. Le detail se lit dans +`htmlcov/index.html` apres `make test-cov`. + +## Lancer les tests + +```bash +make test # suite unitaire, sans base +make test-cov # idem, plus les rapports HTML, XML et JUnit +make db-up && make test-integration # tests exigeant une base, demande Docker +make check # lint + typage + suite unitaire + +uv run pytest tests/api/test_health.py # un seul fichier +uv run pytest -k readiness # par motif de nom +``` diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index fee4044..1c27c08 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -79,9 +79,14 @@ disallow_untyped_defs = false [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" addopts = "-q --strict-markers -m 'not integration' --cov=app --cov-report=term-missing" markers = ["integration: requiert une base PostgreSQL joignable, hors `make test`"] [tool.coverage.run] source = ["app"] -omit = ["app/main.py", "alembic/*"] +branch = true +omit = ["alembic/*"] + +[tool.coverage.report] +show_missing = true diff --git a/apps/backend/tests/api/test_health.py b/apps/backend/tests/api/test_health.py index f0d647f..a7a61b6 100644 --- a/apps/backend/tests/api/test_health.py +++ b/apps/backend/tests/api/test_health.py @@ -1,12 +1,9 @@ -from collections.abc import AsyncIterator +from collections.abc import Callable import pytest -from fastapi import FastAPI from httpx import AsyncClient from sqlalchemy.exc import OperationalError -from app.db.session import get_session - async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None: response = await client.get("/api/v1/health/live") @@ -20,15 +17,10 @@ async def test_liveness_exposes_service_metadata(client: AsyncClient) -> None: } -async def test_readiness_reports_the_timescaledb_version(app: FastAPI, client: AsyncClient) -> None: - class ReadySession: - async def scalar(self, *_: object, **__: object) -> str: - return "2.22.1" - - async def override() -> AsyncIterator[ReadySession]: - yield ReadySession() - - app.dependency_overrides[get_session] = override +async def test_readiness_reports_the_timescaledb_version( + fake_session: Callable[..., None], client: AsyncClient +) -> None: + fake_session(result="2.22.1") response = await client.get("/api/v1/health/ready") @@ -41,16 +33,9 @@ async def test_readiness_reports_the_timescaledb_version(app: FastAPI, client: A async def test_readiness_returns_503_when_the_extension_is_missing( - app: FastAPI, client: AsyncClient + fake_session: Callable[..., None], client: AsyncClient ) -> None: - class SessionWithoutExtension: - async def scalar(self, *_: object, **__: object) -> None: - return None - - async def override() -> AsyncIterator[SessionWithoutExtension]: - yield SessionWithoutExtension() - - app.dependency_overrides[get_session] = override + fake_session(result=None) response = await client.get("/api/v1/health/ready") @@ -67,16 +52,9 @@ async def test_readiness_returns_503_when_the_extension_is_missing( ids=["erreur_sqlalchemy", "erreur_reseau_asyncpg"], ) async def test_readiness_returns_503_when_database_is_unreachable( - app: FastAPI, client: AsyncClient, failure: Exception + fake_session: Callable[..., None], client: AsyncClient, failure: Exception ) -> None: - class UnreachableSession: - async def scalar(self, *_: object, **__: object) -> None: - raise failure - - async def override() -> AsyncIterator[UnreachableSession]: - yield UnreachableSession() - - app.dependency_overrides[get_session] = override + fake_session(failure=failure) response = await client.get("/api/v1/health/ready") diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index ac0e89b..4560b75 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -1,18 +1,30 @@ import os -from collections.abc import AsyncIterator, Iterator +from collections.abc import AsyncIterator, Callable, Iterator import pytest from fastapi import FastAPI from httpx import ASGITransport, AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import get_settings -from app.db.session import get_engine, get_session_factory +from app.db.session import get_engine, get_session, get_session_factory from app.main import create_app +from tests.factories import FakeSession +# Piege : les variables d'environnement priment sur apps/backend/.env. Celles qu'on ne +# pose pas ici, c'est le .env du poste qui les decide, et les assertions avec. @pytest.fixture(autouse=True, scope="session") def environment() -> Iterator[None]: - os.environ.setdefault("APP_SECRET_KEY", "secret-de-test") + os.environ.update( + { + "APP_ENV": "local", + "APP_DEBUG": "false", + "APP_LOG_LEVEL": "WARNING", + "APP_CORS_ORIGINS": "", + "APP_SECRET_KEY": "secret-de-test", + } + ) os.environ.setdefault( "DATABASE_URL", "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test" ) @@ -42,3 +54,21 @@ async def client(app: FastAPI) -> AsyncIterator[AsyncClient]: transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as async_client: yield async_client + + +@pytest.fixture +def fake_session(app: FastAPI) -> Callable[..., None]: + def install(result: object = None, failure: Exception | None = None) -> None: + async def override() -> AsyncIterator[FakeSession]: + yield FakeSession(result=result, failure=failure) + + app.dependency_overrides[get_session] = override + + return install + + +# Contrainte : ouvre une vraie connexion, donc reservee aux tests `integration`. +@pytest.fixture +async def session() -> AsyncIterator[AsyncSession]: + async with get_session_factory()() as async_session: + yield async_session diff --git a/apps/backend/tests/core/__init__.py b/apps/backend/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/db/__init__.py b/apps/backend/tests/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/factories.py b/apps/backend/tests/factories.py new file mode 100644 index 0000000..05ba3fc --- /dev/null +++ b/apps/backend/tests/factories.py @@ -0,0 +1,37 @@ +from typing import Any + +from app.core.config import Settings + +SETTINGS_DE_TEST: dict[str, Any] = { + "env": "local", + "debug": False, + "log_level": "WARNING", + "cors_origins": "", + "secret_key": "secret-de-test", + "database_url": "postgresql+asyncpg://enervision:change_me@localhost:5433/enervision_test", +} + + +class FakeSession: + """Session factice : renvoie `result`, ou leve `failure` si elle est fournie.""" + + def __init__(self, result: object = None, failure: Exception | None = None) -> None: + self._result = result + self._failure = failure + + async def scalar(self, *_: object, **__: object) -> object: + return self._repondre() + + async def execute(self, *_: object, **__: object) -> object: + return self._repondre() + + def _repondre(self) -> object: + if self._failure is not None: + raise self._failure + return self._result + + +# Piege : les arguments nommes priment sur l'environnement et sur .env, contrairement +# aux variables posees par la fixture `environment`, qui restent surchargeables. +def make_settings(**overrides: Any) -> Settings: + return Settings(**{**SETTINGS_DE_TEST, **overrides}) diff --git a/apps/backend/tests/repositories/__init__.py b/apps/backend/tests/repositories/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/tests/services/__init__.py b/apps/backend/tests/services/__init__.py new file mode 100644 index 0000000..e69de29