Cours Python · Module 12: Projet final
Stratégie de test pour l'IA
Dans cette leçon5
Un ranger qui vérifie seulement que le Land Rover démarre sur le parking ne sait toujours pas s'il traversera la rivière. Tester des applications d'IA pose le même problème en double dose. Le code ordinaire est déterministe : la même fonction avec la même entrée donne le même résultat. Un modèle de langage peut répondre à la même question de deux façons différentes. C'est pourquoi il faut vérifier non seulement le code, mais aussi la qualité des réponses.
La pyramide des tests pour l'IA
La pyramide des tests classique dit : le plus de tests unitaires rapides à la base, moins de tests d'intégration au milieu, et le moins de tests de bout en bout, lents, au sommet. Dans un projet d'IA, on ajoute une couche d'évaluation des réponses du modèle. Les proportions ci-dessous sont un objectif indicatif, pas une norme :
1 ╱╲
2 ╱ ╲
3 ╱ E2E╲ ← 10% - Tests de bout en bout
4 ╱──────╲
5 ╱ ╲
6 ╱Integration╲ ← 20% - Tests d'intégration
7 ╱────────────╲
8 ╱ ╲
9 ╱ AI Evaluation ╲ ← 30% - Évaluation de l'IA
10 ╱──────────────────╲
11 ╱ ╲
12 ╱ Unit ╲ ← 40% - Tests unitaires
13 ╱────────────────────────╲La base est la plus large parce que les tests unitaires sont les plus rapides et les moins chers. L'évaluation de l'IA se trouve juste au-dessus : elle est plus lente et coûte souvent de l'argent (chaque question est un appel au modèle). Du moins coûteux au plus coûteux : unitaires, intégration, E2E, et enfin les tests de performance sous charge.
Tests unitaires
On écrit les tests unitaires avec pytest. Une fixture est une fonction marquée @pytest.fixture qui prépare des données ou des ressources pour un test, et pytest l'injecte d'après le nom du paramètre. Mock de unittest.mock crée un objet factice, et spec= garantit que la doublure n'a que les méthodes de la vraie classe.
1import pytest
2from unittest.mock import Mock, AsyncMock
3
4class TestDocumentService:
5 @pytest.fixture
6 def mock_repo(self):
7 return Mock(spec=DocumentRepository)
8
9 @pytest.fixture
10 def mock_embedder(self):
11 embedder = Mock()
12 embedder.embed.return_value = [0.1] * 1536
13 return embedder
14
15 @pytest.fixture
16 def service(self, mock_repo, mock_embedder):
17 return DocumentService(mock_repo, mock_embedder)La fixture service utilise elle-même deux autres fixtures, donc pytest construit tout l'arbre de dépendances pour toi. L'embedder renvoie un vecteur fixe de 1536 nombres, sans aucun appel d'API. La suite de la même classe contient les vrais tests :
1 def test_chunk_document(self, service):
2 doc = Document(content="A" * 1000)
3 chunks = service.chunk_document(doc, chunk_size=200)
4
5 assert len(chunks) == 5
6 assert all(len(c.content) <= 200 for c in chunks)
7
8 @pytest.mark.asyncio
9 async def test_save_document(self, service, mock_repo):
10 doc = Document(title="Test", content="Content")
11 mock_repo.save = AsyncMock(return_value=doc)
12
13 result = await service.save(doc)
14
15 mock_repo.save.assert_called_once()
16 assert result.title == "Test"Le test de découpage vérifie de la logique pure : 1000 caractères en morceaux de 200, ça fait 5 fragments. Le test d'enregistrement est asynchrone, il porte donc le marqueur @pytest.mark.asyncio du plugin pytest-asyncio et utilise AsyncMock, qui renvoie une valeur après await. Une assertion, c'est simplement assert result == expected ou, comme ici, assert result.title == "Test". Pour remplacer une fonction dans un module (par exemple un appel à une API externe), on utilise unittest.mock.patch, comme décorateur ou sous la forme with patch("module.fonction") as fake:.
Tests d'intégration
Un test d'intégration fait tourner plusieurs parties ensemble. Testcontainers démarre un vrai Qdrant dans un conteneur Docker le temps des tests, et httpx.AsyncClient envoie les requêtes directement à l'application ASGI, sans réseau.
1import pytest
2import pytest_asyncio
3from httpx import ASGITransport, AsyncClient
4from testcontainers.community.qdrant import QdrantContainer
5
6@pytest.fixture(scope="module")
7def qdrant_container():
8 with QdrantContainer() as qdrant:
9 yield qdrant
10
11@pytest_asyncio.fixture
12async def app_client(qdrant_container):
13 app = create_app(qdrant_url=f"http://{qdrant_container.rest_host_address}")
14 transport = ASGITransport(app=app)
15 async with AsyncClient(transport=transport, base_url="http://test") as client:
16 yield clientDeux choses ont changé dans les bibliothèques et valent la peine d'être connues. Depuis httpx 0.28, le paramètre app= n'existe plus, tu passes l'application via ASGITransport. Dans pytest-asyncio, une fixture asynchrone se marque avec @pytest_asyncio.fixture. Le conteneur Qdrant s'installe avec testcontainers[qdrant], et les anciennes versions l'importaient depuis testcontainers.qdrant. Voici maintenant le test lui-même :
1@pytest.mark.asyncio
2async def test_upload_and_query(app_client):
3 # Téléverser un document
4 response = await app_client.post(
5 "/documents",
6 json={"title": "Test", "content": "Python is great"}
7 )
8 assert response.status_code == 201
9
10 # Requête
11 response = await app_client.post(
12 "/query",
13 json={"question": "What is Python?"}
14 )
15 assert response.status_code == 200
16 assert "Python" in response.json()["answer"]Le test parcourt tout le flux : upload, puis requête. Remarque que la dernière assertion dépend de la réponse du modèle, elle peut donc être instable. Ce genre de vérification a plutôt sa place dans l'évaluation.
Évaluation de l'IA
L'évaluation mesure la qualité des réponses sur un jeu de questions préparé. Chaque cas contient une question et les mots-clés qu'on s'attend à trouver dans la réponse :
1from dataclasses import dataclass
2
3@dataclass
4class EvalCase:
5 question: str
6 expected_keywords: list[str]
7 context_required: bool = True
8
9eval_dataset = [
10 EvalCase(
11 question="What is RAG?",
12 expected_keywords=["retrieval", "generation", "augmented"],
13 context_required=True
14 ),
15 EvalCase(
16 question="How does embedding work?",
17 expected_keywords=["vector", "semantic", "representation"],
18 context_required=True
19 )
20]Le jeu de données lui-même, ce sont de simples données. La fonction de notation calcule la part des mots-clés présents dans la réponse et vérifie si le système a fourni des sources :
1async def evaluate_rag_system(query_fn, dataset: list[EvalCase]) -> dict:
2 results = {"total": len(dataset), "passed": 0, "failed": []}
3
4 for case in dataset:
5 response = await query_fn(case.question)
6
7 # Vérifier les mots-clés
8 answer_lower = response.answer.lower()
9 keywords_found = sum(1 for k in case.expected_keywords if k in answer_lower)
10 keyword_score = keywords_found / len(case.expected_keywords)
11
12 # Vérifier les sources
13 has_sources = len(response.sources) > 0 if case.context_required else True
14
15 if keyword_score >= 0.5 and has_sources:
16 results["passed"] += 1
17 else:
18 results["failed"].append({
19 "question": case.question,
20 "keyword_score": keyword_score,
21 "has_sources": has_sources
22 })
23
24 results["accuracy"] = results["passed"] / results["total"]
25 return resultsLe résultat, c'est une valeur accuracy plus une liste d'échecs à examiner. Les mots-clés sont une méthode simple et approximative. Les projets plus sérieux évaluent aussi la fidélité de la réponse aux sources, mais le principe reste : un jeu de questions fixe, une mesure reproductible.
Pipeline CI/CD
Les tests n'ont de sens que s'ils se lancent tout seuls à chaque push. GitHub Actions exécute les étapes sur une machine propre, avec Qdrant qui tourne à côté comme service :
1# .github/workflows/test.yml
2name: Test Suite
3
4on: [push, pull_request]
5
6jobs:
7 test:
8 runs-on: ubuntu-latest
9 services:
10 qdrant:
11 image: qdrant/qdrant
12 ports:
13 - 6333:6333
14
15 steps:
16 - uses: actions/checkout@v4
17 - uses: actions/setup-python@v5
18 with:
19 python-version: "3.12"
20
21 - name: Install dependencies
22 run: pip install -r requirements.txt
23
24 - name: Run unit tests
25 run: pytest tests/unit -v
26
27 - name: Run integration tests
28 run: pytest tests/integration -v
29 env:
30 QDRANT_URL: http://localhost:6333
31
32 - name: Run AI evaluation
33 run: python scripts/evaluate.py
34 env:
35 OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}La clé d'API vient des secrets, jamais du dépôt. La couverture de code se mesure avec le plugin pytest-cov : pytest --cov=app indique quelles lignes n'ont jamais été testées. Tu lances un seul fichier avec pytest tests/unit/test_service.py, et beaucoup de pipelines construisent aussi une image après les tests avec docker build -t myapp:latest ., on y reviendra dans la leçon sur le déploiement. Mon conseil : lance l'évaluation de l'IA séparément des tests unitaires, car elle est plus lente et payante. Dans la prochaine leçon, on s'occupera de la documentation.
Souviens-toi : les tests, ce sont des traversées d'essai de la rivière avant d'y mener toute l'expédition.
Tu as repéré une erreur dans cette leçon ?
Teste tes connaissances
Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.
1. Qu'est-ce qui se trouve à la base de la pyramide des tests ?
2. À quoi servent les fixtures de pytest ?
Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.
Tâches pratiques dans le jeu
- Tri vertical
Range les types de tests du plus rapide au plus lent :
- Éditeur de code
Écris un test unitaire pour une fonction de traitement de données avec pytest
- Clique dans l'ordre
Clique sur les éléments pour construire une instruction assert pytest :
- Tri horizontal
Range la commande Docker build dans le bon ordre :
- Éditeur de code
Crée un test utilisant Mock pour simuler une dépendance à une API externe
- Tri vertical
Range les couches de la pyramide des tests IA de la base jusqu'au sommet :