Les tests unitaires prouvent que le code prend les bonnes décisions face à un faux Pub/Sub et un faux Gemini. Ils ne prouvent pas que la notification part vraiment sur le bon sujet, que l'agent Pub/Sub a le droit de déplacer un message, ni que Gemini répond en moins de deux minutes depuis europe-west1. Ces défauts-là n'apparaissent qu'en vrai. Le test de bout en bout de ce projet tourne sur l'infrastructure réelle, avec des assertions, et produit un rapport lisible par le métier.
Le mécanisme
e2e.py exerce chaque branche du contrat sur Google Cloud :
sequenceDiagram participant E as "e2e.py" participant GCS as "Bucket entrant/" participant PS as "Pub/Sub" participant W as "Worker" participant BQ as "BigQuery" E->>GCS: 10 documents (dont 1 vide) E->>PS: 1 message empoisonné (sans eventType) GCS->>PS: 10 OBJECT_FINALIZE PS->>W: 10 + 1 livraisons W->>BQ: 9 ok + 1 rejected W-->>PS: 400 pour le message empoisonné (x10) E->>BQ: attend 10 lignes E->>W: rejeu direct d'un messageId déjà traité W-->>E: 200 duplicate PS->>PS: 10e échec : déplacement en lettres mortes E->>PS: lit la file de lettres mortes E->>E: 5 assertions + sorties/rapport.html
Chaque passage dépose ses documents sous un préfixe unique (entrant/<horodatage>-<aléa>/) : deux exécutions ne se mélangent pas, et les assertions ne comptent que les lignes du passage.
Le rejeu appelle le worker directement avec un jeton d'identité (gcloud auth print-identity-token). Il faut donc que votre compte utilisateur ait run.invoker sur le service ; un propriétaire du projet l'a par héritage.
La mise en œuvre
cd files
make setup # .venv + dépendances, .env créé depuis .env.example
# dans .env : GOOGLE_CLOUD_PROJECT=<votre projet>
make cloud-up # ~2 min au premier passage (API, image, service)
make e2e # ~6 minLes assertions finales du scénario :
checks = {
"10 lignes, une par document": len(again) == len(docs),
"9 analysés, 1 rejeté (vide)": (len(ok), len(rejected)) == (len(docs) - 1, 1),
"aucun message_id en double": len({r["message_id"] for r in again}) == len(again),
"rejeu acquitté comme doublon": rep == {"http": 200, "status": "duplicate"},
"message empoisonné en lettres mortes": bool(dead is not None),
}Les requêtes BigQuery du test sont paramétrées et plafonnées à 100 Mo facturés : une erreur de filtre échoue au lieu de lire toute la table.
Résultat obtenu (2026-10-16)
15:17:33 10 documents déposés sous entrant/20261016-151732-1e59/
15:17:34 message empoisonné publié : 22188242576114171
15:17:35 BigQuery : 0/10 ligne(s)
15:17:45 BigQuery : 6/10 ligne(s)
15:17:56 BigQuery : 10/10 ligne(s)
15:17:56 01-facture-carburant.txt ok facture normale 1133 ms
15:17:56 02-facture-pneumatiques.txt ok facture normale 974 ms
15:17:56 03-reclamation-retard.txt ok reclamation haute 969 ms
15:17:56 04-bon-de-livraison.txt ok bon_de_livraison basse 1101 ms
15:17:56 05-contrat-sous-traitance.txt ok contrat normale 1178 ms
15:17:56 06-relance-impaye.txt ok relance haute 1190 ms
15:17:56 07-reclamation-colis-endommage.txt ok reclamation haute 993 ms
15:17:56 08-avoir-transporteur.txt ok avoir normale 1003 ms
15:17:56 09-note-interne.txt ok autre normale 1129 ms
15:17:56 10-document-vide.txt rejected document vide ms
15:17:58 rejeu du message 22201064101151603 : {'http': 200, 'status': 'duplicate'}
15:18:18 lettres mortes : pas encore arrivé (10 tentatives, délai de 10 à 60 s)
...
15:23:19 lettres mortes : message 22188242576114171 reçu, attributs {... 'CloudPubSubDeadLetterSourceDeliveryCount': '10'}
15:23:20 OK 10 lignes, une par document
15:23:20 OK 9 analysés, 1 rejeté (vide)
15:23:20 OK aucun message_id en double
15:23:20 OK rejeu acquitté comme doublon
15:23:20 OK message empoisonné en lettres mortes
15:23:20 rapport : sorties/rapport.html | tokens consommés : 4664Le rapport HTML, ouvert dans un navigateur :
Ce qu'on lit dans ces chiffres : la latence Gemini est stable autour de 1 s par document (sans raisonnement), le délai médian du dépôt à la ligne BigQuery est de 6,3 s, dont environ 1 s de Gemini ; le reste se partage entre la notification, la livraison Pub/Sub, les deux requêtes BigQuery et, pour les premiers documents, le démarrage de l'instance. Les trois documents haute sont ceux que la comptable attendait : la réclamation avec pénalité, la relance avant mise en demeure, le colis endommagé. L'avoir est extrait avec un montant négatif (-768 €), ce que le prompt ne demandait pas : à décider avec le métier, et à figer dans la description du champ.
Ce qui casse en production
- Test qui passe sur des données d'hier. Sans préfixe unique par passage, un test peut compter les lignes d'une exécution précédente et réussir alors que le pipeline est cassé. Parade : un identifiant de passage dans le chemin et dans les requêtes.
- Démarrage à froid sous rafale. 300 documents déposés d'un coup : Cloud Run démarre jusqu'à 3 instances, les autres livraisons attendent, certaines dépassent le délai d'acquittement. Symptôme : des
duplicateet desdelivery_attempt> 1 le matin. Parade :--min-instances=1aux heures de bureau (facturé en continu), ou accepter quelques secondes de latence. - Test e2e qui coûte. Un test qui envoie 500 documents à chaque commit coûte vite plus que la production. Parade : 10 documents choisis pour couvrir chaque branche (type, vide, empoisonné), lancés à la demande ou chaque nuit, pas à chaque commit ; la CI ne lance que
make test. - Quota partagé. Le quota Gemini du projet est commun à toutes les équipes : un e2e lancé pendant le traitement d'une autre équipe prend des 429. Les réessais l'absorbent ; regardez
delivery_attemptavant de conclure à un défaut.
La règle de décision
Un e2e par chemin du contrat (succès, rejet, doublon, lettres mortes), sur l'infrastructure réelle, avec des assertions qui échouent, pas une simple démonstration. Isolez chaque passage par un préfixe, plafonnez ses coûts, et sortez un artefact que le métier peut relire : c'est lui qui valide la qualité des réponses, pas le code.
Pratique
L'e2e échoue sur « 10 lignes attendues, 9 reçues » après 300 s. Les journaux du worker ne montrent que 9 document_traite et aucune erreur. Où regardez-vous ?
Aucune erreur côté worker signifie qu'il n'a jamais reçu le 10e événement. La cause est en amont : un nom de fichier hors du préfixe entrant/, un dépôt échoué, une notification filtrée. Un problème Gemini ou BigQuery donnerait des document_echec ; les lettres mortes ne reçoivent un message qu'après dix échecs du worker, donc dix lignes d'erreur.
Un collègue propose d'ajouter make e2e à la CI sur chaque pull request, avec le projet Google Cloud de l'équipe. Que répondez-vous ?
L'e2e coûte de l'argent, prend 6 minutes et partage le quota avec la production s'il tourne dans le même projet. Le lancer à chaque PR, c'est payer ce prix 30 fois par jour pour peu d'information de plus. Retirer l'étape des lettres mortes supprimerait le seul test réel des droits de l'agent Pub/Sub.
Exercice. Ajoutez au scénario un 11e document : un fichier .png de 20 Ko. Quelle ligne doit-il produire, et quelles assertions changent ?
Voir la solution
Il doit produire une ligne status=rejected, reason="type non pris en charge : image/png", sans appel Gemini. Dans e2e.py :
upload()doit garder le type du fichier (content_type=mimetypes.guess_type(p)[0]) au lieu de forcertext/plain;docsinclutdata/*.png;- l'assertion devient
(len(ok), len(rejected)) == (len(docs) - 2, 2).
Vérifiez aussi dans BigQuery que input_tokens est NULL pour cette ligne : c'est la preuve que le rejet a eu lieu avant l'appel.
- Je lance
make cloud-uppuismake e2eet je lis chaque assertion. - J'isole chaque passage d'un test réel par un préfixe unique.
- Je lis le rapport avec le métier, pas seulement le code de sortie.
- Je décide où et quand un e2e coûteux s'exécute.