Aller au contenu principal

Tester les API et modeles IA

Theorie 45 min

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
Pannes silencieuses

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

DefiLogiciel classiqueSystemes IA
DeterminismeMeme entree → meme sortie toujoursMeme entree → peut produire des sorties legerement differentes
ExactitudeLa sortie est soit correcte soit fausseLa sortie a un niveau de confiance — « correct » est relatif
Dependance aux donneesLa logique est dans le codeLa logique est apprise des donnees — changer les donnees, changer le comportement
Cas limitesEnsemble fini de conditions aux frontieresEntrees possibles infinies, certaines adverses
DebogageLa pile d'appels pointe vers le bugLe modele est une boite noire — difficile de localiser la panne
RegressionUn changement de code casse un testDerive 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

CoucheTeste quoiVitesseNombreOutils
UnitaireFonctions individuelles, validation des donnees, pretraitement, utilitaires⚡ Tres rapide50-200+pytest, unittest
IntegrationEndpoints API, chargement du modele, connexions base de donnees, interactions entre services🔄 Moyenne20-50pytest + TestClient, httpx
Bout en boutPipeline complet de l'entree brute a la reponse finale dans un environnement proche de la production🐢 Lent5-15Postman, Newman, selenium
Regle empirique

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
Portee de conftest.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 ?

RaisonExplication
VitesseCharger un gros modele prend des secondes — le mocking est instantane
IsolationTester la logique API sans dependre de la precision du modele
DeterminismeLes predictions mockees sont toujours les memes — pas de hasard
CI/CDPas 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()
Quand mocker vs quand utiliser le modele reel
  • 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)
N'oubliez pas ces cas limites
  • 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" en 5.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 couvertureInterpretation
< 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

PratiqueDescription
Nommage des testsUtilisez des noms descriptifs : test_predict_rejects_nan_values
Modele AAAArrange → Act → Assert dans chaque test
Une assertion par conceptChaque test verifie un comportement (plusieurs assert OK s'ils testent la meme chose)
Ne pas tester le frameworkNe testez pas que Pydantic valide — testez VOS regles de validation
Tester le comportement, pas l'implementationTestez ce que la fonction fait, pas comment elle le fait
Garder les tests rapidesMocker les dependances lourdes, utiliser des fixtures, eviter les E/S fichier
Utiliser des fixtures pour la configurationNe pas repeter le code de configuration dans chaque test
Tester le chemin tristeLes entrees invalides, les erreurs et les cas limites comptent plus que le chemin heureux

Points cles a retenir

Points cles a retenir
  1. 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
  2. Suivez la pyramide des tests : beaucoup de tests unitaires, moins de tests d'integration, un minimum de tests E2E
  3. pytest est la reference — maitrisez les fixtures, parametrize et les marqueurs
  4. Utilisez le TestClient pour des tests API rapides et fiables sans demarrer de serveur
  5. Mockez le modele pour tester la logique API ; utilisez le modele reel pour tester les predictions
  6. Testez les cas limites de maniere agressive : NaN, infini, entree vide, mauvais types, valeurs extremes
  7. Mesurez la couverture mais ne vous obsede pas sur 100 % — concentrez-vous sur les chemins critiques
  8. Integrez les tests dans la CI/CD pour qu'ils s'executent automatiquement a chaque push

Ressources supplementaires