Tester les API et modeles IA
Pourquoi tester les systemes IA ?
Les tests logiciels traditionnels sont deja importants — mais tester les systemes bases sur l'IA est encore plus critique en raison de defis uniques qui n'existent pas dans les applications classiques.
L'analogie de la prevision meteorologique
Imaginez un modele IA comme un systeme de prevision meteorologique :
- Les memes conditions atmospheriques peuvent conduire a des previsions differentes selon de minuscules variations
- La qualite des predictions depend entierement de la qualite des donnees historiques
- On ne peut pas simplement verifier « la sortie est-elle correcte ? » — il faut verifier « la sortie est-elle raisonnable ? »
- Un bug peut ne pas faire planter le systeme — il peut produire silencieusement des predictions erronees pendant des semaines
Les bugs les plus dangereux dans les systemes IA sont les pannes silencieuses — le modele continue de renvoyer des predictions, mais ces predictions sont subtilement fausses. Contrairement a une erreur 500, personne ne s'en apercoit avant que des degats reels ne soient causes.
Defis cles du test des systemes IA
| Defi | Logiciel classique | Systemes IA |
|---|---|---|
| Determinisme | Meme entree → meme sortie toujours | Meme entree → peut produire des sorties legerement differentes |
| Exactitude | La sortie est soit correcte soit fausse | La sortie a un niveau de confiance — « correct » est relatif |
| Dependance aux donnees | La logique est dans le code | La logique est apprise des donnees — changer les donnees, changer le comportement |
| Cas limites | Ensemble fini de conditions aux frontieres | Entrees possibles infinies, certaines adverses |
| Debogage | La pile d'appels pointe vers le bug | Le modele est une boite noire — difficile de localiser la panne |
| Regression | Un changement de code casse un test | Derive des donnees, reentrainement ou changement d'environnement casse le comportement |
La pyramide des tests pour l'IA
La pyramide des tests est un concept classique : ecrire beaucoup de tests rapides et peu couteux en bas (tests unitaires) et moins de tests lents et couteux en haut (tests de bout en bout). Pour les systemes IA, nous adaptons cette pyramide pour inclure des couches de test specifiques au modele.
Details des couches
| Couche | Teste quoi | Vitesse | Nombre | Outils |
|---|---|---|---|---|
| Unitaire | Fonctions individuelles, validation des donnees, pretraitement, utilitaires | ⚡ Tres rapide | 50-200+ | pytest, unittest |
| Integration | Endpoints API, chargement du modele, connexions base de donnees, interactions entre services | 🔄 Moyenne | 20-50 | pytest + TestClient, httpx |
| Bout en bout | Pipeline complet de l'entree brute a la reponse finale dans un environnement proche de la production | 🐢 Lent | 5-15 | Postman, Newman, selenium |
Visez une distribution 70/20/10 : 70 % de tests unitaires, 20 % de tests d'integration, 10 % de tests de bout en bout. Cela garde votre suite de tests rapide tout en detectant les problemes reels.
Fondamentaux de pytest
pytest est le framework de test de facto en Python. Il est simple, puissant et extensible. Si vous ne l'avez jamais utilise, vous apprecierez sa syntaxe minimale.
Votre premier test
# test_basics.py
def add(a, b):
return a + b
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, -1) == -2
def test_add_zero():
assert add(0, 0) == 0
Executez-le :
pytest test_basics.py -v
Sortie :
test_basics.py::test_add_positive_numbers PASSED
test_basics.py::test_add_negative_numbers PASSED
test_basics.py::test_add_zero PASSED
========================= 3 passed in 0.02s =========================
Fixtures pytest
Les fixtures sont des fonctions de configuration reutilisables qui fournissent des donnees ou des ressources de test. Pensez-y comme au « travail preparatoire » avant une recette de cuisine.
# conftest.py — fixtures partagees disponibles pour tous les fichiers de test
import pytest
import joblib
import numpy as np
from fastapi.testclient import TestClient
from app.main import app
@pytest.fixture
def client():
"""Create a FastAPI test client."""
return TestClient(app)
@pytest.fixture
def sample_features():
"""Return valid input features for prediction."""
return {
"features": [5.1, 3.5, 1.4, 0.2, 2.3]
}
@pytest.fixture
def trained_model():
"""Load the trained model from disk."""
return joblib.load("models/model_v1.joblib")
@pytest.fixture
def sample_array():
"""Return a NumPy array of sample features."""
return np.array([[5.1, 3.5, 1.4, 0.2, 2.3]])
Utilisation des fixtures dans les tests :
# test_prediction.py
def test_prediction_returns_integer(trained_model, sample_array):
prediction = trained_model.predict(sample_array)
assert isinstance(prediction[0], (int, np.integer))
def test_prediction_is_valid_class(trained_model, sample_array):
prediction = trained_model.predict(sample_array)
assert prediction[0] in [0, 1]
Parametrize — Tester plusieurs entrees
Au lieu d'ecrire 10 tests pour 10 entrees, utilisez @pytest.mark.parametrize pour executer une fonction de test avec plusieurs jeux de donnees :
import pytest
@pytest.mark.parametrize("features,expected_class", [
([5.1, 3.5, 1.4, 0.2, 2.3], [0, 1]), # valid input → class 0 or 1
([6.7, 3.0, 5.2, 2.3, 1.1], [0, 1]), # valid input → class 0 or 1
([4.9, 2.4, 3.3, 1.0, 0.5], [0, 1]), # valid input → class 0 or 1
])
def test_prediction_valid_classes(trained_model, features, expected_class):
import numpy as np
X = np.array([features])
prediction = trained_model.predict(X)
assert prediction[0] in expected_class
Marqueurs — Categoriser les tests
Utilisez les marqueurs pour etiqueter les tests et executer des sous-ensembles :
import pytest
@pytest.mark.slow
def test_model_training_from_scratch():
"""This test takes 30+ seconds — skip in CI fast runs."""
...
@pytest.mark.integration
def test_api_predict_endpoint(client, sample_features):
response = client.post("/api/v1/predict", json=sample_features)
assert response.status_code == 200
@pytest.mark.unit
def test_feature_validation():
from app.schemas import PredictionRequest
req = PredictionRequest(features=[1.0, 2.0, 3.0, 4.0, 5.0])
assert len(req.features) == 5
Executez uniquement les tests unitaires rapides :
pytest -m "unit" -v
pytest -m "not slow" -v
Enregistrez les marqueurs dans pytest.ini :
# pytest.ini
[pytest]
markers =
unit: Unit tests (fast)
integration: Integration tests (medium speed)
slow: Slow tests (skip in CI fast runs)
e2e: End-to-end tests
conftest.py — Le centre de configuration des tests
conftest.py est un fichier special que pytest decouvre automatiquement. C'est la ou vous placez les fixtures partagees, les hooks et les plugins :
project/
├── conftest.py # Root-level fixtures (available everywhere)
├── tests/
│ ├── conftest.py # Test-specific fixtures
│ ├── unit/
│ │ ├── conftest.py # Unit test fixtures
│ │ ├── test_schemas.py
│ │ └── test_utils.py
│ ├── integration/
│ │ ├── conftest.py # Integration test fixtures
│ │ └── test_api.py
│ └── e2e/
│ └── test_full_pipeline.py
Les fixtures dans conftest.py sont disponibles pour tous les tests du meme repertoire et des sous-repertoires. Aucune importation necessaire — pytest les trouve automatiquement.
Tester les endpoints API
Pour les API IA construites avec FastAPI, nous utilisons le TestClient (base sur httpx) pour simuler des requetes HTTP sans demarrer un serveur reel.
Test basique d'un endpoint
# tests/integration/test_api.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health_endpoint():
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
assert "model_loaded" in data
def test_predict_valid_input():
payload = {"features": [5.1, 3.5, 1.4, 0.2, 2.3]}
response = client.post("/api/v1/predict", json=payload)
assert response.status_code == 200
data = response.json()
assert "prediction" in data
assert "confidence" in data
assert data["prediction"] in [0, 1]
assert 0.0 <= data["confidence"] <= 1.0
def test_predict_returns_consistent_schema():
"""Verify the response always has the expected shape."""
payload = {"features": [5.1, 3.5, 1.4, 0.2, 2.3]}
response = client.post("/api/v1/predict", json=payload)
data = response.json()
expected_keys = {"prediction", "confidence", "model_version"}
assert expected_keys.issubset(data.keys())
Tester les reponses d'erreur
def test_predict_missing_features():
response = client.post("/api/v1/predict", json={})
assert response.status_code == 422 # Pydantic validation error
def test_predict_wrong_type():
payload = {"features": "not a list"}
response = client.post("/api/v1/predict", json=payload)
assert response.status_code == 422
def test_predict_wrong_feature_count():
payload = {"features": [1.0, 2.0]} # expects 5, got 2
response = client.post("/api/v1/predict", json=payload)
assert response.status_code == 400
assert "features" in response.json()["detail"].lower()
def test_predict_invalid_json():
response = client.post(
"/api/v1/predict",
content="this is not json",
headers={"Content-Type": "application/json"}
)
assert response.status_code == 422
Mocker les modeles ML dans les tests
Parfois, vous ne voulez pas que les tests dependent d'un fichier de modele reel. Le mocking remplace le modele reel par un faux qui renvoie des resultats predictibles.
Pourquoi mocker ?
| Raison | Explication |
|---|---|
| Vitesse | Charger un gros modele prend des secondes — le mocking est instantane |
| Isolation | Tester la logique API sans dependre de la precision du modele |
| Determinisme | Les predictions mockees sont toujours les memes — pas de hasard |
| CI/CD | Pas besoin de stocker de gros fichiers de modele dans votre pipeline de test |
Mocking avec unittest.mock
# tests/unit/test_with_mock.py
from unittest.mock import MagicMock, patch
import numpy as np
def test_predict_endpoint_with_mocked_model(client):
mock_model = MagicMock()
mock_model.predict.return_value = np.array([1])
mock_model.predict_proba.return_value = np.array([[0.15, 0.85]])
with patch("app.ml.model_service.model", mock_model):
response = client.post(
"/api/v1/predict",
json={"features": [5.1, 3.5, 1.4, 0.2, 2.3]}
)
assert response.status_code == 200
data = response.json()
assert data["prediction"] == 1
assert data["confidence"] == 0.85
mock_model.predict.assert_called_once()
def test_predict_handles_model_exception(client):
mock_model = MagicMock()
mock_model.predict.side_effect = RuntimeError("Model crashed")
with patch("app.ml.model_service.model", mock_model):
response = client.post(
"/api/v1/predict",
json={"features": [5.1, 3.5, 1.4, 0.2, 2.3]}
)
assert response.status_code == 500
assert "error" in response.json()["detail"].lower()
- Mocker pour tester le routage API, la validation, la gestion des erreurs, le format de reponse
- Utiliser le modele reel pour tester la precision des predictions, les performances du modele, le comportement des cas limites
Tester la validation des donnees
Les schemas Pydantic sont votre premiere ligne de defense. Testez-les en profondeur :
# tests/unit/test_schemas.py
import pytest
from pydantic import ValidationError
from app.schemas import PredictionRequest, PredictionResponse
class TestPredictionRequest:
def test_valid_request(self):
req = PredictionRequest(features=[1.0, 2.0, 3.0, 4.0, 5.0])
assert len(req.features) == 5
def test_rejects_empty_features(self):
with pytest.raises(ValidationError):
PredictionRequest(features=[])
def test_rejects_too_few_features(self):
with pytest.raises(ValidationError):
PredictionRequest(features=[1.0, 2.0])
def test_rejects_string_features(self):
with pytest.raises(ValidationError):
PredictionRequest(features=["a", "b", "c", "d", "e"])
def test_accepts_integer_features(self):
req = PredictionRequest(features=[1, 2, 3, 4, 5])
assert all(isinstance(f, float) for f in req.features)
def test_rejects_none_values(self):
with pytest.raises(ValidationError):
PredictionRequest(features=[1.0, None, 3.0, 4.0, 5.0])
class TestPredictionResponse:
def test_valid_response(self):
resp = PredictionResponse(
prediction=1,
confidence=0.95,
model_version="1.0.0"
)
assert resp.prediction == 1
def test_confidence_in_range(self):
with pytest.raises(ValidationError):
PredictionResponse(
prediction=1,
confidence=1.5, # > 1.0
model_version="1.0.0"
)
Tester les cas limites
Les cas limites sont des entrees aux frontieres de ce que votre systeme peut gerer. Pour les systemes IA, ils sont particulierement delicats :
# tests/unit/test_edge_cases.py
import pytest
import numpy as np
class TestEdgeCases:
def test_empty_input(self, client):
response = client.post("/api/v1/predict", json={"features": []})
assert response.status_code in [400, 422]
def test_null_input(self, client):
response = client.post("/api/v1/predict", json={"features": None})
assert response.status_code == 422
def test_missing_body(self, client):
response = client.post("/api/v1/predict")
assert response.status_code == 422
def test_extremely_large_values(self, client):
payload = {"features": [1e308, 1e308, 1e308, 1e308, 1e308]}
response = client.post("/api/v1/predict", json=payload)
assert response.status_code in [200, 400]
def test_nan_values(self, client):
payload = {"features": [float("nan"), 1.0, 2.0, 3.0, 4.0]}
response = client.post("/api/v1/predict", json=payload)
assert response.status_code == 400
def test_infinity_values(self, client):
payload = {"features": [float("inf"), 1.0, 2.0, 3.0, 4.0]}
response = client.post("/api/v1/predict", json=payload)
assert response.status_code == 400
def test_negative_values(self, client):
payload = {"features": [-100.0, -50.0, -25.0, -10.0, -1.0]}
response = client.post("/api/v1/predict", json=payload)
assert response.status_code == 200
def test_all_zeros(self, client):
payload = {"features": [0.0, 0.0, 0.0, 0.0, 0.0]}
response = client.post("/api/v1/predict", json=payload)
assert response.status_code == 200
def test_very_long_feature_list(self, client):
payload = {"features": [1.0] * 1000}
response = client.post("/api/v1/predict", json=payload)
assert response.status_code in [400, 422]
def test_concurrent_requests(self, client):
"""Verify the API handles rapid sequential requests."""
payload = {"features": [5.1, 3.5, 1.4, 0.2, 2.3]}
responses = [
client.post("/api/v1/predict", json=payload)
for _ in range(20)
]
assert all(r.status_code == 200 for r in responses)
- NaN et Infini : Les operations NumPy peuvent produire silencieusement des NaN — assurez-vous que votre API les detecte et les rejette
- Coercition de type : Pydantic peut convertir silencieusement
"5"en5.0— decidez si c'est acceptable - Entree Unicode : Et si quelqu'un envoie
"features": ["cafe", "resume"]?
Couverture des tests
La couverture des tests mesure le pourcentage de votre code execute par les tests. Ce n'est pas une metrique de qualite — 100 % de couverture ne signifie pas que vos tests sont bons — mais une faible couverture est un signal d'alarme.
Utiliser pytest-cov
pip install pytest-cov
pytest --cov=app --cov-report=term-missing -v
Exemple de sortie :
---------- coverage: platform linux, python 3.11 ----------
Name Stmts Miss Cover Missing
---------------------------------------------------------
app/__init__.py 0 0 100%
app/main.py 25 2 92% 41-42
app/ml/model_service.py 18 0 100%
app/schemas.py 15 0 100%
---------------------------------------------------------
TOTAL 58 2 97%
Generer un rapport HTML
pytest --cov=app --cov-report=html
# Ouvrez htmlcov/index.html dans votre navigateur
Seuils de couverture en CI
pytest --cov=app --cov-fail-under=80
Cette commande echoue si la couverture descend en dessous de 80 % — parfait pour les pipelines CI.
| Niveau de couverture | Interpretation |
|---|---|
| < 50 % | 🔴 Dangereusement bas — lacunes majeures dans les tests |
| 50-70 % | 🟡 Acceptable pour les etapes precoces |
| 70-85 % | 🟢 Bon — la plupart des chemins critiques couverts |
| 85-95 % | 🟢 Tres bon — haute confiance |
| > 95 % | 🔵 Excellent — mais attention aux rendements decroissants |
Integration des tests en CI/CD
Les tests ne sont utiles que s'ils s'executent automatiquement a chaque changement de code. Voici comment integrer pytest dans votre pipeline CI/CD.
Exemple GitHub Actions
# .github/workflows/test.yml
name: Run Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov httpx
- name: Run unit tests
run: pytest tests/unit -v --cov=app --cov-report=xml
- name: Run integration tests
run: pytest tests/integration -v
- name: Check coverage threshold
run: pytest --cov=app --cov-fail-under=80
- name: Upload coverage report
uses: codecov/codecov-action@v4
with:
file: ./coverage.xml
Flux du pipeline de test
Voir le pipeline CI/CD
Resume des bonnes pratiques
| Pratique | Description |
|---|---|
| Nommage des tests | Utilisez des noms descriptifs : test_predict_rejects_nan_values |
| Modele AAA | Arrange → Act → Assert dans chaque test |
| Une assertion par concept | Chaque test verifie un comportement (plusieurs assert OK s'ils testent la meme chose) |
| Ne pas tester le framework | Ne testez pas que Pydantic valide — testez VOS regles de validation |
| Tester le comportement, pas l'implementation | Testez ce que la fonction fait, pas comment elle le fait |
| Garder les tests rapides | Mocker les dependances lourdes, utiliser des fixtures, eviter les E/S fichier |
| Utiliser des fixtures pour la configuration | Ne pas repeter le code de configuration dans chaque test |
| Tester le chemin triste | Les entrees invalides, les erreurs et les cas limites comptent plus que le chemin heureux |
Points cles a retenir
- Les systemes IA ont besoin de plus de tests, pas moins — les pannes silencieuses, le non-determinisme et la dependance aux donnees les rendent fragiles
- Suivez la pyramide des tests : beaucoup de tests unitaires, moins de tests d'integration, un minimum de tests E2E
- pytest est la reference — maitrisez les fixtures, parametrize et les marqueurs
- Utilisez le TestClient pour des tests API rapides et fiables sans demarrer de serveur
- Mockez le modele pour tester la logique API ; utilisez le modele reel pour tester les predictions
- Testez les cas limites de maniere agressive : NaN, infini, entree vide, mauvais types, valeurs extremes
- Mesurez la couverture mais ne vous obsede pas sur 100 % — concentrez-vous sur les chemins critiques
- Integrez les tests dans la CI/CD pour qu'ils s'executent automatiquement a chaque push