rriviere commited on
Commit
d2d5ef4
·
unverified ·
2 Parent(s): 528908288f79b5

Merge pull request #2 from RaphaelRIVIERE/feature/test

Browse files
Files changed (8) hide show
  1. README.md +28 -0
  2. app/main.py +1 -1
  3. pytest.ini +2 -0
  4. requirements.txt +6 -0
  5. tests/.gitkeep +0 -0
  6. tests/conftest.py +36 -0
  7. tests/test_api.py +40 -0
  8. tests/test_model.py +66 -0
README.md CHANGED
@@ -195,5 +195,33 @@ Réponse :
195
  | `label` | string | `"Reste"` ou `"Quitte"` |
196
  | `probabilite` | float | Probabilité de départ (entre 0 et 1) |
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  ## Déploiement
199
  *À compléter*
 
195
  | `label` | string | `"Reste"` ou `"Quitte"` |
196
  | `probabilite` | float | Probabilité de départ (entre 0 et 1) |
197
 
198
+ ## Tests
199
+
200
+ ### Lancer les tests
201
+
202
+ ```bash
203
+ pytest tests/ -v
204
+ ```
205
+
206
+ ### Rapport de couverture
207
+
208
+ Affichage dans le terminal :
209
+ ```bash
210
+ pytest tests/ --cov=app --cov-report=term-missing
211
+ ```
212
+
213
+ Rapport HTML navigable (généré dans `htmlcov/`) :
214
+ ```bash
215
+ pytest tests/ --cov=app --cov-report=term-missing --cov-report=html
216
+ ```
217
+
218
+ ### Structure des tests
219
+
220
+ | Fichier | Contenu |
221
+ |---|---|
222
+ | `tests/conftest.py` | Fixtures partagées (client de test, payload valide) |
223
+ | `tests/test_api.py` | Tests des endpoints HTTP (health, predict, auth) |
224
+ | `tests/test_model.py` | Tests du pipeline ML (chargement, prédiction, seuil) |
225
+
226
  ## Déploiement
227
  *À compléter*
app/main.py CHANGED
@@ -28,7 +28,7 @@ async def lifespan(app: FastAPI):
28
  app = FastAPI(
29
  title="Futurisys HR Churn API",
30
  description="API de prédiction du risque de départ des employés",
31
- version="0.2.0",
32
  lifespan=lifespan,
33
  )
34
 
 
28
  app = FastAPI(
29
  title="Futurisys HR Churn API",
30
  description="API de prédiction du risque de départ des employés",
31
+ version="0.3.0",
32
  lifespan=lifespan,
33
  )
34
 
pytest.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [pytest]
2
+ pythonpath = .
requirements.txt CHANGED
@@ -3,6 +3,7 @@ annotated-types==0.7.0
3
  anyio==4.12.1
4
  certifi==2026.2.25
5
  click==8.3.1
 
6
  dnspython==2.8.0
7
  email-validator==2.3.0
8
  fastapi==0.133.1
@@ -14,18 +15,23 @@ httpcore==1.0.9
14
  httptools==0.7.1
15
  httpx==0.28.1
16
  idna==3.11
 
17
  Jinja2==3.1.6
18
  joblib==1.5.3
19
  markdown-it-py==4.0.0
20
  MarkupSafe==3.0.3
21
  mdurl==0.1.2
22
  numpy==2.4.2
 
23
  pandas==3.0.1
 
24
  pydantic==2.12.5
25
  pydantic-extra-types==2.11.0
26
  pydantic-settings==2.13.1
27
  pydantic_core==2.41.5
28
  Pygments==2.19.2
 
 
29
  python-dateutil==2.9.0.post0
30
  python-dotenv==1.2.1
31
  python-multipart==0.0.22
 
3
  anyio==4.12.1
4
  certifi==2026.2.25
5
  click==8.3.1
6
+ coverage==7.13.4
7
  dnspython==2.8.0
8
  email-validator==2.3.0
9
  fastapi==0.133.1
 
15
  httptools==0.7.1
16
  httpx==0.28.1
17
  idna==3.11
18
+ iniconfig==2.3.0
19
  Jinja2==3.1.6
20
  joblib==1.5.3
21
  markdown-it-py==4.0.0
22
  MarkupSafe==3.0.3
23
  mdurl==0.1.2
24
  numpy==2.4.2
25
+ packaging==26.0
26
  pandas==3.0.1
27
+ pluggy==1.6.0
28
  pydantic==2.12.5
29
  pydantic-extra-types==2.11.0
30
  pydantic-settings==2.13.1
31
  pydantic_core==2.41.5
32
  Pygments==2.19.2
33
+ pytest==9.0.2
34
+ pytest-cov==7.0.0
35
  python-dateutil==2.9.0.post0
36
  python-dotenv==1.2.1
37
  python-multipart==0.0.22
tests/.gitkeep DELETED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from unittest.mock import MagicMock, patch
3
+ from fastapi.testclient import TestClient
4
+ from app.main import app
5
+
6
+ TEST_API_KEY = "test-secret-key"
7
+
8
+ VALID_PAYLOAD = {
9
+ "age": 35, "genre": 1, "statut_marital": "Marié(e)",
10
+ "poste": "Consultant", "domaine_etude": "Infra & Cloud",
11
+ "niveau_education": 3, "nombre_experiences_precedentes": 2,
12
+ "annee_experience_totale": 10, "annees_dans_l_entreprise": 5,
13
+ "annees_dans_le_poste_actuel": 2, "annees_sous_responsable_actuel": 3,
14
+ "annees_depuis_la_derniere_promotion": 1,
15
+ "note_evaluation_actuelle": 3, "note_evaluation_precedente": 3,
16
+ "augmentation_salaire_precedente": 15, "nb_formations_suivies": 2,
17
+ "nombre_participation_pee": 1,
18
+ "satisfaction_employee_environnement": 3,
19
+ "satisfaction_employee_nature_travail": 4,
20
+ "satisfaction_employee_equipe": 3,
21
+ "satisfaction_employee_equilibre_pro_perso": 2,
22
+ "heure_supplementaires": 0, "frequence_deplacement": 1,
23
+ "distance_domicile_travail": 10, "revenu_mensuel": 5000,
24
+ }
25
+
26
+ @pytest.fixture(scope="module")
27
+ def client():
28
+ mock_pipeline = MagicMock()
29
+ # Simule predict_proba → [[proba_reste, proba_quitte]]
30
+ # 0.3 < seuil 0.40 → prédiction "Reste"
31
+ mock_pipeline.predict_proba.return_value = [[0.70, 0.30]]
32
+
33
+ with patch("app.main.load_pipeline", return_value=(mock_pipeline, 0.40)), \
34
+ patch.dict("os.environ", {"API_KEY": TEST_API_KEY}):
35
+ with TestClient(app) as c:
36
+ yield c
tests/test_api.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from tests.conftest import VALID_PAYLOAD, TEST_API_KEY
2
+
3
+
4
+ # Tester GET /health
5
+ def test_health_check(client):
6
+ response = client.get("/health")
7
+ assert response.status_code == 200
8
+ assert response.json()["status"] == "ok"
9
+
10
+
11
+ # Tester POST /predict avec données valides
12
+ def test_predict_valid(client):
13
+ response = client.post(
14
+ "/predict",
15
+ json=VALID_PAYLOAD,
16
+ headers={"X-API-Key": TEST_API_KEY},
17
+ )
18
+ assert response.status_code == 200
19
+ body = response.json()
20
+ assert "label" in body
21
+ assert body["label"] in ("Reste", "Quitte")
22
+ assert "probabilite" in body
23
+ assert 0.0 <= body["probabilite"] <= 1.0
24
+
25
+
26
+ # Tester POST /predict avec données invalides
27
+ def test_predict_invalid_data(client):
28
+ invalid_payload = {"age": 999, "genre": 99} # hors bornes + champs manquants
29
+ response = client.post(
30
+ "/predict",
31
+ json=invalid_payload,
32
+ headers={"X-API-Key": TEST_API_KEY},
33
+ )
34
+ assert response.status_code == 422 # Pydantic validation error
35
+
36
+
37
+ # Tester POST /predict sans API Key → 401
38
+ def test_predict_no_api_key(client):
39
+ response = client.post("/predict", json=VALID_PAYLOAD)
40
+ assert response.status_code == 401
tests/test_model.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from unittest.mock import MagicMock, patch
3
+ from fastapi.testclient import TestClient
4
+
5
+ from ml_model.loader import load_pipeline
6
+ from app.main import app
7
+ from tests.conftest import VALID_PAYLOAD, TEST_API_KEY
8
+
9
+
10
+ # Fixture : client avec le VRAI pipeline (pas de mock)
11
+ @pytest.fixture(scope="module")
12
+ def real_client():
13
+ with patch.dict("os.environ", {"API_KEY": TEST_API_KEY}):
14
+ with TestClient(app) as c:
15
+ yield c
16
+
17
+
18
+ # Tester que le pipeline .pkl se charge correctement
19
+ def test_load_pipeline():
20
+ pipeline, threshold = load_pipeline()
21
+ assert pipeline is not None
22
+ assert isinstance(threshold, float)
23
+
24
+
25
+ # Tester que la prédiction retourne bien "Reste" ou "Quitte"
26
+ def test_predict_label_valid(real_client):
27
+ response = real_client.post(
28
+ "/predict",
29
+ json=VALID_PAYLOAD,
30
+ headers={"X-API-Key": TEST_API_KEY},
31
+ )
32
+ assert response.status_code == 200
33
+ assert response.json()["label"] in ("Reste", "Quitte")
34
+
35
+
36
+ # Tester que la probabilité est bien comprise entre 0 et 1
37
+ def test_predict_probabilite_range(real_client):
38
+ response = real_client.post(
39
+ "/predict",
40
+ json=VALID_PAYLOAD,
41
+ headers={"X-API-Key": TEST_API_KEY},
42
+ )
43
+ assert 0.0 <= response.json()["probabilite"] <= 1.0
44
+
45
+
46
+ # Tester que le seuil 0.40 est bien appliqué
47
+ @pytest.mark.parametrize("proba_quitte,expected_label", [
48
+ (0.50, "Quitte"), # clairement au-dessus du seuil
49
+ (0.30, "Reste"), # clairement en-dessous du seuil
50
+ ])
51
+ def test_threshold_applied(proba_quitte, expected_label):
52
+ _, threshold = load_pipeline() # ← vrai seuil (0.3999...7)
53
+ mock_pipeline = MagicMock()
54
+ mock_pipeline.predict_proba.return_value = [[1 - proba_quitte, proba_quitte]]
55
+
56
+ with patch("app.main.load_pipeline", return_value=(mock_pipeline, threshold)), \
57
+ patch.dict("os.environ", {"API_KEY": TEST_API_KEY}):
58
+ with TestClient(app) as c:
59
+ response = c.post(
60
+ "/predict",
61
+ json=VALID_PAYLOAD,
62
+ headers={"X-API-Key": TEST_API_KEY},
63
+ )
64
+
65
+ assert response.json()["label"] == expected_label
66
+