From 98ec01c847393da2049bd0fcaf52ff267c56b46d Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 09:54:15 +0200 Subject: [PATCH 1/9] test(backend): rend la configuration de test independante du poste APP_ENV, APP_DEBUG, APP_LOG_LEVEL et APP_CORS_ORIGINS n'etaient poses nulle part : le .env du developpeur les decidait, alors que les tests assertent en dur l'environnement et que create_app coupe /openapi.json hors developpement. Un poste portant APP_ENV=prod faisait tomber deux tests. Fixe aussi asyncio_default_fixture_loop_scope, que pytest-asyncio 1.4 reclame. --- apps/backend/pyproject.toml | 1 + apps/backend/tests/conftest.py | 12 +++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index fee4044..be64aba 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -79,6 +79,7 @@ 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`"] diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index ac0e89b..e855f1b 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -10,9 +10,19 @@ from app.db.session import get_engine, get_session_factory from app.main import create_app +# 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" ) From 3ca1866e93ba179bd3bf02140bd078393c0e5450 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 09:55:38 +0200 Subject: [PATCH 2/9] test(backend): factorise les doubles de session Chaque test reecrivait sa classe de session et sa fonction d'override, soit trois fois le meme decor pour un seul endpoint. FakeSession et la fixture fake_session portent ce decor, make_settings fabrique une Settings dont les valeurs priment sur l'environnement. --- apps/backend/tests/api/test_health.py | 40 ++++++--------------------- apps/backend/tests/conftest.py | 16 +++++++++-- apps/backend/tests/factories.py | 37 +++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 33 deletions(-) create mode 100644 apps/backend/tests/factories.py 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 e855f1b..950fc69 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -1,13 +1,14 @@ 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 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 @@ -52,3 +53,14 @@ 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 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}) From 3db4419bdf57d86c7a34e510f5e8d9b06bff1ba2 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 09:58:30 +0200 Subject: [PATCH 3/9] test(backend): calque l'arborescence des tests sur celle de app Le README annonce deja tests/ comme miroir de app/, mais seul tests/api existait. Les paquets core, db, services et repositories attendent le metier a venir, pour que personne n'ait a choisir ou poser son premier test. --- apps/backend/tests/core/__init__.py | 0 apps/backend/tests/db/__init__.py | 0 apps/backend/tests/repositories/__init__.py | 0 apps/backend/tests/services/__init__.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 apps/backend/tests/core/__init__.py create mode 100644 apps/backend/tests/db/__init__.py create mode 100644 apps/backend/tests/repositories/__init__.py create mode 100644 apps/backend/tests/services/__init__.py 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/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 From c95f4d38515c8215074a69d01ace57347e95d55b Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 09:58:57 +0200 Subject: [PATCH 4/9] test(backend): mesure les branches et fixe un seuil de couverture app/main.py sort du omit : la fixture app l'exerce a chaque test, et l'exclure masquait ses seules conditions, les docs coupees hors developpement et le CORS monte selon les origines declarees. Il ressort a 78 %, le lifespan n'etant pas joue par ASGITransport. Seuil pose a 85 % pour 89 % mesures. --- apps/backend/pyproject.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index be64aba..87948f1 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -85,4 +85,9 @@ 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 +fail_under = 85 From d14b3afc8ea2095006b03503be0ada464f29c1e3 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 09:59:17 +0200 Subject: [PATCH 5/9] chore: ajoute une cible de rapports de tests make test-cov produit la couverture HTML et XML et les resultats au format JUnit, sans alourdir make test qui reste la boucle de developpement. Les trois artefacts sont ignores, contrairement au junit.xml versione cote frontend. --- .gitignore | 1 + Makefile | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) 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..1426c5a 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}' @@ -25,6 +25,10 @@ typecheck: ## Verifie le typage du backend test: ## Execute les tests backend ne demandant pas de base cd $(BACKEND) && uv run pytest +test-cov: ## Rapports de couverture HTML et XML, plus les resultats au format JUnit + cd $(BACKEND) && uv run pytest --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 From d58647cad4286e9b63ef61ae32a65310f749d03d Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 10:00:42 +0200 Subject: [PATCH 6/9] test(backend): fournit une session reelle aux tests d'integration Les repositories a venir parlent du SQL : les eprouver sur un double ne prouve rien. La fixture ouvre une vraie connexion, d'ou le marqueur integration. --- apps/backend/tests/conftest.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/backend/tests/conftest.py b/apps/backend/tests/conftest.py index 950fc69..4560b75 100644 --- a/apps/backend/tests/conftest.py +++ b/apps/backend/tests/conftest.py @@ -4,6 +4,7 @@ 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, get_session_factory @@ -64,3 +65,10 @@ def fake_session(app: FastAPI) -> Callable[..., None]: 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 From 50dfa72c9dc75917aaf139e9d3e6773a51b4b447 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 10:01:09 +0200 Subject: [PATCH 7/9] test(backend): n'applique le seuil de couverture qu'aux suites completes Dans [tool.coverage.report], fail_under vaut aussi pour une execution partielle : make test-integration echouait a 71 % alors que son test passait, et un fichier joue seul aurait echoue des que le code aurait grossi. Le seuil passe donc en --cov-fail-under sur les cibles qui jouent toute la suite. --- Makefile | 6 +++--- apps/backend/pyproject.toml | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 1426c5a..81c3e6d 100644 --- a/Makefile +++ b/Makefile @@ -23,11 +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-report=html --cov-report=xml \ - --junitxml=test-results/junit.xml + 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/apps/backend/pyproject.toml b/apps/backend/pyproject.toml index 87948f1..1c27c08 100644 --- a/apps/backend/pyproject.toml +++ b/apps/backend/pyproject.toml @@ -90,4 +90,3 @@ omit = ["alembic/*"] [tool.coverage.report] show_missing = true -fail_under = 85 From 08ad3bbe3486ba916dcf99ea44e268832599e9ec Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 10:01:21 +0200 Subject: [PATCH 8/9] docs(backend): consigne les conventions de tests unitaires Pendant de apps/frontend/TESTING.md : ou ecrire un test, comment le nommer, quoi tester selon la couche, les doubles par dependency_overrides, les marqueurs, et quatre gabarits copiables. --- apps/backend/TESTING.md | 136 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 apps/backend/TESTING.md diff --git a/apps/backend/TESTING.md b/apps/backend/TESTING.md new file mode 100644 index 0000000..5cc8912 --- /dev/null +++ b/apps/backend/TESTING.md @@ -0,0 +1,136 @@ +# 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 + + +@pytest.mark.integration +async def test_repository_reads_back_what_it_wrote(session: AsyncSession) -> 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 +``` From 6aeaca8ed118b03a5adc898249609fa4fd6800b5 Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Tue, 15 Sep 2026 10:01:34 +0200 Subject: [PATCH 9/9] docs: renvoie vers les conventions de tests Ajoute test/ a la liste des prefixes de branches, deja utilise par la branche d'outillage frontend, et remplace le corps a trous du gabarit de repository par un exemple complet, que ruff format acceptait mal. --- README.md | 2 +- apps/backend/README.md | 3 +++ apps/backend/TESTING.md | 9 ++++++++- 3 files changed, 12 insertions(+), 2 deletions(-) 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 index 5cc8912..f794421 100644 --- a/apps/backend/TESTING.md +++ b/apps/backend/TESTING.md @@ -104,10 +104,17 @@ marqueur `integration`, ecarte par defaut. 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