Quatrième article de la série « Production Engineering, Evals & Security avec Claude ».
Une eval peut vous dire qu’un système a échoué.
Elle ne vous dit pas nécessairement où.
C’est précisément le rôle combiné des tests et du tracing : les tests servent à isoler différents types de défaillances, tandis que les traces permettent de reconstruire l’exécution pour identifier l’étape responsable. Le document distingue quatre niveaux complémentaires : unit, functional, integration et end-to-end.
Pourquoi plusieurs niveaux de tests sont nécessaires
Une application Claude réelle ressemble souvent à ceci :
Input
↓
Retrieval
↓
Prompt builder
↓
Claude API
↓
Tool / Parser
↓
Output
Le problème est que chaque composant peut fonctionner parfaitement lorsqu’il est testé seul, alors que le workflow complet échoue.
C’est pourquoi il faut tester à plusieurs niveaux.
1. Unit test : vérifier une fonction isolée
Un unit test teste une seule unité de code.
Exemples :
parser
tool wrapper
normalizer
format converter
retry helper
Supposons un parser :
def parse_amount(data):
return float(data["amount"])
Un test unitaire pourrait vérifier :
def test_parse_amount():
result = parse_amount({"amount": "42.5"})
assert result == 42.5
Ce test répond uniquement à la question :
Cette fonction fonctionne-t-elle correctement lorsqu’on lui fournit l’entrée attendue ?
Il ne vérifie pas que le composant précédent produit réellement ce format.
Limite du unit test
Si le composant précédent renvoie :
{"value": "42.5"}
au lieu de :
{"amount": "42.5"}
le parser peut parfaitement réussir tous ses tests unitaires et casser malgré tout en production.
Le unit test vérifie l’unité.
Pas le contrat entre les unités.
2. Functional test : tester un appel Claude
Le document définit le functional test comme un test portant sur un appel Claude et la forme de sa réponse.
Par exemple :
def test_extract_shape():
result = call_claude(
"Extract the order date and issue."
)
assert "primary_date" in result
assert "issue" in result
On vérifie que :
Claude call
↓
expected response shape
est respecté.
Cela peut couvrir :
- présence de champs ;
- type attendu ;
- réponse parseable ;
- format structuré.
Ce qu’un functional test ne vérifie pas
Il ne couvre pas nécessairement :
- le retrieval ;
- la construction du prompt ;
- le passage d’un outil à un autre ;
- la sérialisation d’un résultat ;
- le workflow complet.
Autrement dit :
Claude fonctionne
≠
le système fonctionne
3. Integration test : tester la jonction entre composants
C’est probablement le niveau le plus important à retenir pour l’examen.
Le document souligne que de nombreuses défaillances silencieuses se trouvent au niveau des handoffs entre composants.
Exemple :
Retrieval
↓
Prompt builder
Le retrieval renvoie :
[
{"content": "Refunds are allowed within 30 days."},
{"content": "Escalations require manager approval."}
]
Mais le prompt builder attend :
"Refunds are allowed within 30 days..."
Le résultat peut alors être inséré sous une mauvaise forme dans le prompt.
Exemple du document : tous les composants passent, le workflow casse
Le document donne un scénario très représentatif.
Les tests isolés passent :
PASS test_parser_unit
PASS test_extract_shape_functional
Mais l’end-to-end échoue.
La trace montre :
step 1 retrieve(q)
OK
→ 3 chunks as list of dicts
step 2 build_prompt(ctx)
OK
→ raw list inserted
step 3 model.call(prompt)
OK
→ answer ignores context
step 4 assertion
FAIL
La cause est précise :
retrieve()
returns:
list[dict]
build_prompt()
expects:
string
Aucun composant n’est nécessairement cassé en isolation.
Le contrat entre eux est faux.
C’est exactement ce qu’un integration test doit détecter.
Un integration test pour ce cas
On peut tester directement la frontière :
def test_retrieval_to_prompt():
chunks = retrieve("refund policy")
prompt = build_prompt(chunks)
assert "30 days" in prompt
Ou mieux, tester avec de vraies données de retrieval :
def test_retrieval_model_handoff():
chunks = retrieve("refund policy")
answer = answer_with_context(chunks)
assert "30 days" in answer
L’objectif est de vérifier :
output composant A
↓
input composant B
avec les formats réels.
4. End-to-end test : tester comme un utilisateur
Un end-to-end test exécute le workflow complet.
Par exemple :
User question
↓
Retrieval
↓
Prompt
↓
Claude
↓
Tool calls
↓
Parser
↓
Final answer
Le test peut vérifier :
def test_full_flow():
answer = application.ask(
"How long do I have to request a refund?"
)
assert "30 days" in answer
Ce test est particulièrement utile pour détecter :
- des erreurs d’orchestration ;
- des problèmes de configuration ;
- des dépendances mal reliées ;
- des comportements qui n’apparaissent qu’une fois le système assemblé.
Limite de l’end-to-end
Un E2E vous dit :
FAIL
mais ne dit pas nécessairement :
why
Il peut être difficile de savoir si le problème vient de :
- retrieval ;
- prompt ;
- modèle ;
- tool ;
- parser ;
- réseau ;
- format.
C’est précisément pourquoi le tracing devient nécessaire.
Tableau des quatre niveaux
| Niveau | Ce qu’il isole | Ce qu’il ne peut pas garantir |
|---|---|---|
Unit | Une fonction isolée | Les interactions entre composants |
Functional | Un appel Claude | Le workflow autour de cet appel |
Integration | Le handoff entre composants | Tous les comportements globaux |
End-to-end | Le système complet | La localisation précise de la panne |
C’est l’une des tables de référence centrales du document.
Le tracing : transformer « ça ne marche pas » en diagnostic
Supposons qu’une eval retourne :
score = 0
Cette information est utile, mais insuffisante.
Avec un trace :
[trace run_id=8f21c]
step 1 retrieve(query)
ok
42 ms
→ 3 chunks
step 2 build_prompt(chunks)
ok
1 ms
→ 1240 tokens
step 3 model.call(prompt)
ok
980 ms
→ answer
step 4 parse(answer)
FAIL
2 ms
→ KeyError: amount
final score: 0
Le diagnostic devient immédiat :
failure
→ step 4
→ parser
→ missing field
Le document résume cette valeur du tracing ainsi : les tests indiquent qu’un échec existe ; la trace montre quelle étape l’a produit.
Que doit enregistrer un trace ?
Selon le document, un trace peut enregistrer :
- le prompt ;
- les tool calls ;
- les résultats intermédiaires ;
- le timing ;
- les erreurs ;
- les étapes successives.
Conceptuellement :
TRACE
│
├── input
├── retrieval output
├── prompt
├── model call
├── tool_use
├── tool_result
├── parser
├── latency
└── final score
L’objectif n’est pas d’accumuler des logs sans structure.
L’objectif est de pouvoir reconstruire la séquence exacte de l’exécution.
Test + trace = diagnostic exploitable
Prenons un scénario :
eval fails
Sans autre information :
Something is wrong.
Avec un E2E :
The workflow is broken.
Avec une trace :
Step 2 passed malformed retrieval data
to step 3.
Avec un integration test spécifique :
The retrieval → prompt contract
is broken.
La progression est donc :
Eval
→ détecte
E2E
→ reproduit
Trace
→ localise
Integration test
→ protège contre la régression
Une règle pratique : transformer chaque incident en test ciblé
Après avoir trouvé un bug de production, il faut éviter qu’il réapparaisse.
Le workflow devient :
incident
↓
trace
↓
root cause
↓
targeted test
↓
fix
↓
regression protection
Si le bug provient du handoff :
retrieval → prompt
on ajoute un integration test.
S’il provient du parser :
unit test
S’il apparaît uniquement dans le système complet :
end-to-end test
Le niveau du test doit correspondre au niveau du défaut.
Exemple : format contract entre composants
Une bonne pratique implicite derrière l’exemple du document consiste à rendre les contrats explicites.
Mauvaise architecture :
def retrieve(query):
return [
{"content": "..."}
]
def build_prompt(context):
return f"Context: {context}"
Ici, personne n’indique clairement le format attendu.
Une version plus robuste peut normaliser :
def format_chunks(chunks):
return "\n\n".join(
chunk["content"]
for chunk in chunks
)
Puis :
def build_prompt(chunks):
context = format_chunks(chunks)
return f"""
Use the following context:
{context}
"""
Et surtout :
def test_chunk_contract():
chunks = retrieve("refund")
context = format_chunks(chunks)
assert isinstance(context, str)
assert "refund" in context.lower()
Le test formalise le contrat.
Le document relie aussi tests et stratégie de retrieval
La section associe également le choix de retrieval à la forme de la tâche.
Deux approches sont distinguées :
fetch once
et :
agentic search
Fetch-once retrieval
Pour une question simple sur un corpus stable :
Question
↓
Retrieve once
↓
Claude
↓
Answer
Exemple :
Quelle est la durée de remboursement indiquée dans cette politique ?
Si la réponse se trouve dans un seul passage connu, plusieurs tours de recherche peuvent être inutiles.
Agentic search
Pour une question plus complexe :
Question
↓
Search
↓
Read
↓
Refine query
↓
Search again
↓
Synthesize
Cela convient davantage à des questions :
- multi-step ;
- nécessitant plusieurs sources ;
- utilisant un corpus changeant ;
- nécessitant des recherches successives.
Mais cela augmente :
token cost
+
latency
Le document propose donc éventuellement un routeur.
Router : choisir la stratégie de retrieval
Exemple conceptuel fourni dans le module :
def route(query):
kind = classify(query)
if kind == "lookup":
return fetch_once(query)
return agentic_search(query)
La logique est :
simple lookup
→ fetch once
multi-step
→ iterative / agentic search
Un petit appel de classification peut être rentable lorsque les requêtes sont réellement mixtes.
En revanche, si toutes les requêtes ont la même forme, le document recommande de ne pas ajouter inutilement ce routeur.
Pourquoi ce sujet appartient à la production engineering
Parce qu’un retrieval trop complexe peut produire :
more calls
more latency
more tokens
more failure points
alors qu’un retrieval trop simple peut produire :
incomplete answer
Le choix devient donc un compromis mesurable entre :
quality
cost
latency
reliability
Les evals déterminent si la stratégie répond suffisamment bien.
Les traces permettent de voir où elle échoue.
Exemple complet de debugging
Supposons :
Question:
"What is the refund deadline?"
Le système répond :
"I don't have enough information."
Étape 1 : eval
FAIL
Étape 2 : trace
retrieve
→ found correct document
build_prompt
→ malformed context
Claude
→ ignored context
Étape 3 : diagnostic
integration failure
Étape 4 : correction
Transformer :
[{"content": "..."}]
en :
"..."
avant injection dans le prompt.
Étape 5 : test ajouté
def test_retrieval_prompt_contract():
chunks = retrieve(...)
prompt = build_prompt(chunks)
assert "30 days" in prompt
On ne se contente donc pas de corriger le bug.
On empêche sa réapparition.
Attention aux tests trop mockés
Le document ne développe pas une doctrine générale sur le mocking, mais son exemple d’intégration illustre un principe important : un test qui utilise uniquement des inputs artificiellement bien formés peut manquer précisément les erreurs présentes dans les données réelles.
Dans le scénario du module :
functional model test
utilise une entrée correcte.
Il passe.
Mais :
real retrieval output
a une autre structure.
C’est uniquement l’integration test utilisant ce format réel qui révèle le problème.
Pour tester un handoff, utilisez autant que possible le contrat réel du composant précédent.
Les tests ne remplacent pas les evals
Il faut distinguer deux questions.
Les tests classiques demandent souvent :
Le système respecte-t-il un contrat technique ?
Par exemple :
JSON parseable ?
field present ?
exception ?
L’eval demande plutôt :
Le comportement est-il suffisamment bon ?
Par exemple :
summary faithful ?
answer complete ?
correct date selected ?
Architecture :
TESTS
→ technical correctness
EVALS
→ behavioral quality
TRACING
→ localization
Les trois sont complémentaires.
Exemple de pipeline de qualité
CHANGE
│
↓
Unit tests
│
↓
Functional tests
│
↓
Integration tests
│
↓
E2E tests
│
↓
Evals
│
↓
Regression detected?
┌──────┴───────┐
│ │
Yes No
│ │
↓ ↓
Trace Release
│
↓
Root cause
Ce n’est pas nécessairement une prescription exacte de CI/CD du document, mais cela reflète directement les rôles qu’il attribue aux tests, evals et traces.
Ce qu’il faut retenir pour la certification
Unit test
one function
Exemple :
parser
tool wrapper
Functional test
one Claude call
Vérifie typiquement :
shape
type
parseability
Integration test
component A
↓
handoff
↓
component B
C’est le test clé pour les erreurs de contrat entre composants.
Le document souligne que beaucoup de défaillances silencieuses apparaissent précisément ici.
End-to-end
user input
→ entire workflow
→ final output
Très bon pour détecter une panne globale.
Moins bon pour la localiser.
Tracing
step-by-step execution history
Il montre :
où
quand
avec quelles données
le workflow a échoué.
Pièges d’examen
Scénario : le parser passe tous ses tests et Claude retourne correctement la structure attendue dans ses functional tests, mais l’application complète échoue parce que la sortie du retrieval n’a pas le format attendu par le prompt builder.
→ Integration test.
Scénario : vous voulez vérifier uniquement que votre wrapper de tool convertit correctement une exception.
→ Unit test.
Scénario : vous voulez vérifier qu’un appel Claude retourne un JSON parseable possédant trois champs.
→ Functional test.
Scénario : vous voulez reproduire exactement ce qu’un utilisateur fait du début à la fin.
→ End-to-end test.
Scénario : l’end-to-end échoue mais vous ignorez à quelle étape.
→ Examiner le trace.
Scénario : tous les composants fonctionnent individuellement mais le workflow échoue lorsqu’ils sont combinés.
→ Chercher d’abord le handoff / integration seam.
À retenir en une phrase
Les tests répondent à des questions différentes selon leur niveau : unit pour une fonction, functional pour un appel Claude, integration pour les handoffs, end-to-end pour le workflow complet ; le tracing permet ensuite de localiser précisément l’étape responsable d’un échec.

