Aller au contenu
Tests et qualité dans le pipeline

Tests et qualité dans le pipeline

100 Comprendre ⏱ 1 h ci-cddockerpythonpostgresql

À la fin, vous saurez

  • Classer les vérifications d'un pipeline par coût, par vitesse et par ce qu'elles détectent
  • Ordonner les étapes pour qu'un défaut soit signalé le plus tôt possible
  • Exécuter des tests d'intégration contre un service éphémère, propre à chaque exécution
  • Interpréter une mesure de couverture sans en faire un objectif
  • Reconnaître un test instable, en trouver la cause et le corriger

Prérequis

Testé avec docker 29.8.1 postgresql 18.6 pytest 9.1.1 pytest-cov 7.1.0 pytest-randomly 5.0.0 python 3.14.7 ruff 0.16.9 , vérifié le 1 octobre 2026

Pourquoi

Un pipeline n'a de valeur que s'il est rapide et fiable. Les deux défauts symétriques le tuent aussi sûrement l'un que l'autre :

  • Un pipeline lent est contourné. Si la vérification d'un commit prend quarante minutes, les développeurs regroupent leurs changements pour ne la lancer qu'une fois, poussent sans attendre le résultat, ou apprennent à la sauter. Les lots grossissent, et l'on retrouve les problèmes de la leçon 1.
  • Un pipeline peu fiable est ignoré. S'il échoue une fois sur dix sans raison, l'équipe prend l'habitude de relancer jusqu'au vert, puis de ne plus regarder le rouge. Le jour où il échoue pour une vraie raison, personne ne le croit.

La question de cette leçon est donc : quelles vérifications mettre dans le pipeline, dans quel ordre, pour que le verdict arrive vite et soit digne de confiance ? La réponse passe par un peu de théorie des tests, puis par la pratique : ajouter à notre serveur de la leçon 2 de vrais tests d'intégration contre PostgreSQL, puis débusquer un test instable.

Les concepts

Les familles de vérifications

FamilleExemplesCe qu'elle détecteCoût typique
Analyse statiqueformatage (ruff format), lint (ruff check), types (mypy), Dockerfile (hadolint)Erreurs de style, noms non définis, constructions dangereuses, sans exécuter le codeSecondes
Tests unitairestest_app.py en mode mémoireLogique d'une fonction ou d'un module, isolé de ses dépendancesSecondes
Tests d'intégrationl'application contre un vrai PostgreSQLDialogue avec les dépendances réelles : SQL, réseau, sérialisationDizaines de secondes
Tests de bout en boutl'application démarrée, interrogée par HTTPLe système assemblé, tel qu'un utilisateur le voitMinutes
Vérifications non fonctionnellesperformance, analyse de vulnérabilités, accessibilitéCe qui n'est pas un comportement mais une qualitéVariable

Ces familles ne se remplacent pas : elles regardent le logiciel à des distances différentes. L'analyse statique de la leçon 1 aurait trouvé le nom non défini _memoire en une seconde (règle F821 de Ruff) ; elle ne trouvera jamais une requête SQL qui cite une colonne inexistante, que seul PostgreSQL connaît.

La pyramide des tests

Mike Cohn a popularisé en 2009 l'image d'une pyramide : beaucoup de tests unitaires à la base, moins de tests d'intégration au milieu, peu de tests de bout en bout au sommet. Ham Vocke, dans The Practical Test Pyramid (2018), en retient deux règles plutôt que des proportions : écrire des tests de granularités différentes, et en avoir d'autant moins qu'ils sont gros et lents.

    flowchart TB
  E["Bout en bout<br/>peu, lents, proches de l'utilisateur"]
  I["Intégration<br/>dialogue avec les vraies dépendances"]
  U["Unitaires<br/>nombreux, rapides, précis"]
  S["Analyse statique<br/>immédiate, sans exécuter"]
  E --- I --- U --- S
  

La raison est économique : un test de bout en bout coûte cher à écrire, cher à exécuter, et quand il échoue, il dit « quelque chose ne va pas » sans dire quoi. Un test unitaire qui échoue désigne une fonction. Mais un logiciel testé uniquement par des tests unitaires peut avoir toutes ses briques correctes et être faux une fois assemblé : c'est ce que montre la suite de la leçon.

L'ordre des étapes : échouer tôt

Le principe d'ordonnancement est simple : ce qui est rapide et échoue souvent passe en premier. Le formatage échoue souvent (un fichier oublié) et coûte deux secondes ; inutile de démarrer PostgreSQL pour découvrir ensuite une ligne trop longue. Les étapes coûteuses (intégration, bout en bout, construction d'image) ne s'exécutent que si les étapes bon marché sont passées.

Humble et Farley structurent ce principe en distinguant deux temps dans le pipeline. L'étape de commit (commit stage) regroupe compilation, analyse statique et tests unitaires ; elle doit répondre en quelques minutes (ils visent moins de cinq, dix au plus), parce que le développeur attend son verdict avant de passer à autre chose. Les étapes d'acceptation qui suivent (intégration, bout en bout, performance) peuvent durer plus longtemps, parce qu'elles tournent pendant que le développeur travaille déjà sur la suite. C'est la même idée que la séparation pré-soumission et post-soumission décrite par Google : avant la fusion, seulement les tests rapides et fiables.

Le budget de temps

L'Extreme Programming parlait de la « construction en dix minutes », et Fowler juge encore ce chiffre raisonnable. Il ne s'agit pas d'une loi, mais d'un ordre de grandeur lié à l'attention humaine : en dessous de dix minutes, on attend le résultat ; au-delà, on passe à autre chose, et le retour arrive quand on ne l'attend plus. Traitez la durée du pipeline comme un budget : chaque étape ajoutée doit le respecter, ou en justifier le dépassement.

Des tests hermétiques

Un test hermétique ne dépend que de ce qui est dans le dépôt et de ce que le pipeline fournit : pas de base de données partagée entre équipes, pas d'API externe, pas d'horloge réelle, pas d'ordre d'exécution. Les auteurs du chapitre CI de Software Engineering at Google résument l'intérêt : exécuté deux fois avec le même code, un test hermétique donne le même résultat. Quand il échoue, c'est le code qui a changé.

Les tests instables

Un test instable (flaky test) réussit et échoue sur le même code. Ce n'est pas un défaut marginal. John Micco rapportait en 2016 que, chez Google, environ 1,5 % de toutes les exécutions de tests donnaient un résultat instable, que près de 16 % des tests présentaient une instabilité, et que 84 % des passages du vert au rouge observés par la CI impliquaient un test instable. Chaque instabilité coûte une enquête, et surtout de la confiance.

L'étude de Luo, Hariri, Eloussi et Marinov (FSE 2014), sur des projets libres, en a classé les causes. Les trois premières :

CausePartExempleCorrection habituelle
Attente asynchroneenviron 45 %sleep 2 en espérant que le serveur ait démarréAttendre activement un signal (port ouvert, réponse), avec un délai maximal
Concurrenceenviron 20 %Deux fils d'exécution modifient la même donnéeSynchronisation, ou test sans concurrence
Dépendance à l'ordre des testsenviron 12 %Un test suppose un état laissé, ou non, par un autreRemettre l'état partagé à zéro avant chaque test

La couverture

La couverture de code mesure la proportion des lignes (ou des branches) exécutées par les tests. Elle répond bien à une question précise : quel code n'a jamais été exécuté par aucun test ? Elle répond mal à la question « le code est-il bien testé ? », parce qu'une ligne exécutée n'est pas une ligne vérifiée : un test sans assertion couvre beaucoup et ne prouve rien. Fixer un seuil de couverture obligatoire produit, par la loi de Goodhart, des tests écrits pour le seuil. Servez-vous en comme d'une carte des zones non testées.

En pratique

Des services pour les tests d'intégration

Signalements a deux modes : en mémoire, sans DATABASE_URL, et avec PostgreSQL. Les tests de test_app.py n'exercent que le premier. Tout le code SQL n'a donc jamais été exécuté par un test. Pour le tester, il faut un PostgreSQL pendant le pipeline : neuf, propre à cette exécution, détruit ensuite.

Les plateformes de CI appellent cela un service (services: chez GitHub Actions comme chez GitLab CI) : un conteneur démarré à côté des étapes, joignable par un nom. Ajoutons cette fonction à notre orchestrateur executer. Trois modifications suffisent : un réseau Docker privé par exécution, le démarrage des services, et un nettoyage garanti.

 git archive "$commit" | tar -x -C "$travail/src"
 echo "pipeline n°$numero : ${ref#refs/heads/} @ ${commit:0:7}"

+# Un réseau privé par exécution, supprimé à la fin avec les services
+reseau="ci-$numero"
+docker network create "$reseau" > /dev/null
+nettoyer() {
+  docker rm -f $(docker ps -aq --filter "label=ci.execution=$numero") > /dev/null 2>&1
+  docker network rm "$reseau" > /dev/null
+}
+trap nettoyer EXIT
+
 # 2. La définition du pipeline vient du dépôt lui-même
@@
 while read -r etape image commande; do
   case "$etape" in ''|'#'*) continue ;; esac
+  if [ "$etape" = service ]; then   # ligne : service <nom> <image> [VARIABLE=valeur...]
+    nom=$image; set -- $commande; image=$1; shift
+    env=(); for variable in "$@"; do env+=(-e "$variable"); done
+    docker run -d --label "ci.execution=$numero" --network "$reseau" \
+      --network-alias "$nom" "${env[@]}" "$image" > /dev/null
+    echo "  ...    service $nom ($image) démarré"
+    continue
+  fi
   debut=$(date +%s)
   if docker run --rm --user "$(id -u):$(id -g)" -e HOME=/tmp \
        -e PIP_CACHE_DIR=/cache/pip -e PIP_DISABLE_PIP_VERSION_CHECK=1 \
-       -v "$base/cache":/cache -v "$travail/src":/src -w /src \
+       --network "$reseau" -v "$base/cache":/cache -v "$travail/src":/src -w /src \
        "$image" sh -c "$commande" > "$travail/journaux/$etape.log" 2>&1 < /dev/null
  • docker network create "ci-$numero" : chaque exécution a son réseau. Deux pipelines simultanés ne voient pas les services l'un de l'autre.
  • --network-alias "$nom" : le service est joignable sous le nom choisi dans .pipeline (ici db), grâce au DNS interne de Docker.
  • --label "ci.execution=$numero" : une étiquette pour retrouver, et supprimer, tous les conteneurs de cette exécution.
  • trap nettoyer EXIT : la fonction de nettoyage s'exécute à la sortie du script, quelle qu'en soit la raison : succès, échec, ou interruption. Sans elle, chaque pipeline rouge laisserait un PostgreSQL orphelin sur l'agent.
  • Le service démarre à l'endroit où il apparaît dans .pipeline : si une étape précédente échoue, on n'a pas payé son démarrage.

Les tests d'intégration

Dans le dépôt de Signalements, formatez d'abord le code, puisque le pipeline va le vérifier (exercice 2 de la leçon 2) : ruff format . dans un conteneur Python, puis commit. Ajoutez ensuite pytest-cov à requirements-dev.txt :

-r requirements.txt
pytest==9.1.1
pytest-cov==7.1.0
ruff==0.16.9

Créez test_integration.py :

"""Tests d'intégration : l'application contre un vrai PostgreSQL.

Ignorés si DATABASE_URL n'est pas définie. L'application est importée dans
chaque test, car son import crée la table et exige donc une base joignable.
"""

import os

import pytest

pytestmark = pytest.mark.skipif(
    not os.environ.get("DATABASE_URL"), reason="DATABASE_URL non définie"
)


def test_stockage_postgresql():
    from app import app

    accueil = app.test_client().get("/").get_json()
    assert accueil["stockage"] == "postgresql"
    assert app.test_client().get("/sante").get_json() == {"etat": "ok"}


def test_creer_consulter_lister():
    from app import app

    client = app.test_client()
    cree = client.post(
        "/signalements",
        json={"lieu": "Quai de Saône", "description": "Garde-corps descellé"},
    )
    assert cree.status_code == 201
    ident = cree.get_json()["id"]
    assert client.get(f"/signalements/{ident}").get_json()["lieu"] == "Quai de Saône"
    assert client.get("/signalements/999999").status_code == 404
    assert any(s["id"] == ident for s in client.get("/signalements").get_json())

Un service qui vient de démarrer n'est pas encore prêt : PostgreSQL initialise son répertoire de données pendant quelques secondes. Plutôt qu'une attente fixe (la première cause de tests instables, on l'a vu), un petit script ci/attendre_postgres.py essaie de se connecter jusqu'à ce que ce soit possible :

"""Attend que PostgreSQL accepte les connexions, une trentaine de tentatives."""

import os
import sys
import time

import psycopg

for _ in range(30):
    try:
        psycopg.connect(os.environ["DATABASE_URL"], connect_timeout=2).close()
        sys.exit(0)
    except psycopg.OperationalError:
        time.sleep(1)
sys.exit("PostgreSQL injoignable après une trentaine de tentatives")

Il rend la main dès que la base répond, et échoue avec un message clair si elle ne répond jamais.

Le pipeline ordonné

Le nouveau .pipeline :

# étape       image              commande
format        python:3.14-slim   pip install --user --quiet ruff==0.16.9 && python -m ruff format --check --no-cache .
lint          python:3.14-slim   pip install --user --quiet ruff==0.16.9 && python -m ruff check --no-cache .
unitaires     python:3.14-slim   pip install --user --quiet -r requirements-dev.txt && python -m pytest -q -p no:cacheprovider --cov=app --junitxml=rapport-unitaires.xml
service       db                 postgres:18.6   POSTGRES_PASSWORD=ci
integration   python:3.14-slim   export DATABASE_URL=postgresql://postgres:ci@db/postgres && pip install --user --quiet -r requirements-dev.txt && python ci/attendre_postgres.py && python -m pytest -q -p no:cacheprovider --cov=app --junitxml=rapport-integration.xml test_integration.py

L'ordre suit le principe d'échec rapide : deux vérifications statiques de deux secondes, les tests unitaires, et seulement ensuite le service et les tests d'intégration. L'adresse db dans DATABASE_URL est l'alias réseau du service.

$ git add -A && git commit -m "Tests d'intégration avec PostgreSQL, formatage vérifié"
$ git push -q ci main
remote: pipeline n°7 : main @ 7223926
remote:   OK     format (3 s)
remote:   OK     lint (2 s)
remote:   OK     unitaires (6 s)
remote:   ...    service db (postgres:18.6) démarré
remote:   OK     integration (6 s)
remote: résultat : succes (journaux dans /home/.../serveur-ci/travaux/7/journaux)
$ docker ps -a --filter label=ci.execution
CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

La dernière commande le confirme : aucun conteneur de service ne subsiste après l'exécution.

Ce que dit la couverture

Les journaux des deux étapes de tests contiennent la mesure de couverture d'app.py :

$ cat serveur-ci/travaux/7/journaux/unitaires.log
...ss                                                                    [100%]
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.7-final-0 ________________

Name     Stmts   Miss  Cover
----------------------------
app.py      57     24    58%
----------------------------
TOTAL       57     24    58%
3 passed, 2 skipped in 0.20s
$ cat serveur-ci/travaux/7/journaux/integration.log
..                                                                       [100%]
...
Name     Stmts   Miss  Cover
----------------------------
app.py      57      9    84%
----------------------------
TOTAL       57      9    84%
2 passed in 0.36s

(Les journaux contiennent aussi des avertissements de pip sur le répertoire /tmp/.local/bin, absent du PATH : ils sont sans conséquence, puisque les outils sont lancés avec python -m.)

Les deux s de la première ligne sont les tests d'intégration, ignorés faute de DATABASE_URL. Le chiffre important est le premier : 42 % du code de l'application, tout le code qui parle à PostgreSQL, n'était exécuté par aucun test avant cette leçon. La couverture ne dit pas si les tests sont bons ; elle dit, sans ambiguïté, où il n'y en a pas.

Un défaut que seule l'intégration voit

Dominique veut afficher les signalements du plus récent au plus ancien. En mémoire, il suffit d'inverser la liste ; en SQL, de trier par date de création :

 def lister():
     if not DATABASE_URL:
-        return jsonify(_signalements)
+        return jsonify(_signalements[::-1])
     with connexion() as cnx:
         lignes = cnx.execute(
-            "SELECT id, lieu, description FROM signalements ORDER BY id"
+            "SELECT id, lieu, description FROM signalements ORDER BY cree DESC"
         ).fetchall()
$ git commit -am "Lister du plus récent au plus ancien" && git push -q ci main
remote: pipeline n°8 : main @ 259ecc0
remote:   OK     format (2 s)
remote:   OK     lint (3 s)
remote:   OK     unitaires (5 s)
remote:   ...    service db (postgres:18.6) démarré
remote:   ÉCHEC  integration (6 s), fin du journal :
remote:          ----------------------------
remote:          TOTAL       57     10    82%
remote:          =========================== short test summary info ============================
remote:          FAILED test_integration.py::test_creer_consulter_lister - TypeError: 'NoneTyp...
remote:          1 failed, 1 passed in 0.43s
remote: résultat : echec (journaux dans /home/.../serveur-ci/travaux/8/journaux)

L'analyse statique et les tests unitaires sont passés : ni Ruff ni le mode mémoire ne connaissent les colonnes de la table. Le résumé affiché à la poussée ne montre qu'un symptôme (TypeError: 'NoneType'..., le test a reçu une réponse vide). La cause est plus haut dans le journal complet :

$ grep -B 10 -A 2 UndefinedColumn serveur-ci/travaux/8/journaux/integration.log
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
  File "/src/app.py", line 53, in lister
    lignes = cnx.execute(
             ~~~~~~~~~~~^
        "SELECT id, lieu, description FROM signalements ORDER BY cree DESC"
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ).fetchall()
    ^
  File "/tmp/.local/lib/python3.14/site-packages/psycopg/connection.py", line 304, in execute
    raise ex.with_traceback(None)
psycopg.errors.UndefinedColumn: column "cree" does not exist
LINE 1: ...T id, lieu, description FROM signalements ORDER BY cree DESC
                                                              ^

La colonne s'appelle cree_le. On corrige, en ajoutant id pour départager deux signalements créés dans la même transaction :

$ sed -i 's/ORDER BY cree DESC"/ORDER BY cree_le DESC, id DESC"/' app.py
$ git commit -am "Lister : trier sur la colonne cree_le" && git push -q ci main
remote: pipeline n°9 : main @ dfce431
remote:   OK     format (3 s)
remote:   OK     lint (2 s)
remote:   OK     unitaires (5 s)
remote:   OK     integration (6 s)

Sans l'étape d'intégration, cette erreur serait partie en production, où la liste des signalements aurait renvoyé une erreur 500 à chaque appel.

Un test qui dépend de l'ordre

Ajoutons en tête de test_app.py un test d'apparence innocente : au départ, la liste est vide.

def test_liste_vide_au_depart():
    assert app.test_client().get("/signalements").get_json() == []

Exécuté dans l'ordre du fichier, il passe : c'est le premier test, rien n'a encore été créé. Mais il suppose cet ordre. Le greffon pytest-randomly exécute les tests dans un ordre aléatoire, déterminé par une graine que l'on peut fixer pour rejouer un ordre précis. Ajoutez pytest-randomly==5.0.0 à requirements-dev.txt, puis comparons l'ordre du fichier (-p no:randomly désactive le greffon) et douze graines :

$ python -m pytest -q -p no:cacheprovider -p no:randomly test_app.py | tail -1
4 passed in 0.21s
$ for graine in $(seq 1 12); do printf "graine %s : " $graine
    python -m pytest -v -p no:cacheprovider --randomly-seed=$graine test_app.py | grep '::' ...
  done
graine 1 : test_accueil ok test_creer_puis_lister ok test_liste_vide_au_depart KO test_detail ok
graine 2 : test_creer_puis_lister ok test_accueil ok test_detail ok test_liste_vide_au_depart KO
graine 3 : test_detail ok test_accueil ok test_liste_vide_au_depart KO test_creer_puis_lister ok
graine 4 : test_accueil ok test_creer_puis_lister ok test_liste_vide_au_depart KO test_detail ok
graine 5 : test_creer_puis_lister ok test_accueil ok test_liste_vide_au_depart KO test_detail ok
graine 6 : test_detail ok test_creer_puis_lister ok test_accueil ok test_liste_vide_au_depart KO
graine 7 : test_accueil ok test_liste_vide_au_depart ok test_detail ok test_creer_puis_lister ok
graine 8 : test_accueil ok test_creer_puis_lister ok test_detail ok test_liste_vide_au_depart KO
graine 9 : test_detail ok test_liste_vide_au_depart KO test_accueil ok test_creer_puis_lister ok
graine 10 : test_detail ok test_liste_vide_au_depart KO test_creer_puis_lister ok test_accueil ok
graine 11 : test_creer_puis_lister ok test_detail ok test_liste_vide_au_depart KO test_accueil ok
graine 12 : test_creer_puis_lister ok test_liste_vide_au_depart KO test_detail ok test_accueil ok

(Le filtre complet, qui ne garde que le nom et le verdict de chaque test, est omis pour la lisibilité.)

Une seule graine sur douze, la 7, place test_liste_vide_au_depart avant les deux tests qui créent des signalements. Le test réussit seul et échoue en compagnie : c'est une dépendance à l'ordre. Dans un pipeline qui exécute les tests en parallèle ou dans un ordre non garanti, il deviendrait instable, rouge un jour, vert le lendemain, sans changement de code.

La cause est un état partagé : la liste _signalements vit dans le module, et chaque test y laisse ses données. La correction, celle de 74 % des cas de ce type dans l'étude de Luo et ses coauteurs, est de remettre l'état à zéro avant chaque test. Avec pytest, une fixture autouse s'exécute avant chaque test du fichier :

import pytest

import app as module_app
from app import app


@pytest.fixture(autouse=True)
def memoire_vide():
    """Chaque test part d'une liste vide, quel que soit l'ordre d'exécution."""
    module_app._signalements.clear()
$ for graine in $(seq 1 12); do python -m pytest -q -p no:cacheprovider --randomly-seed=$graine test_app.py | tail -1; done | sort | uniq -c
      4 4 passed in 0.10s
      8 4 passed in 0.11s

Douze ordres, douze succès (les deux lignes ne diffèrent que par la durée). Gardez pytest-randomly dans le pipeline : il révélera la prochaine dépendance à l'ordre dès son introduction. Pour pouvoir rejouer un échec, la graine doit apparaître dans le journal ; pytest l'affiche dans son en-tête, que l'option -q supprime. Retirez donc -q de l'étape unitaires :

$ grep randomly-seed serveur-ci/travaux/11/journaux/unitaires.log
Using --randomly-seed=4245979383

En cas d'échec, pytest --randomly-seed=4245979383 rejoue exactement le même ordre sur votre poste.

Sous le capot

Comment l'étape trouve db. Sur un réseau Docker créé par l'utilisateur, chaque conteneur interroge le serveur DNS intégré au démon, à l'adresse 127.0.0.11, qui résout les noms et alias des conteneurs du même réseau. C'est pourquoi l'alias fonctionne sur ci-7 et ne fonctionnerait pas sur le réseau bridge par défaut (cours Docker : les fondamentaux, leçon 10). GitHub Actions fait exactement la même chose quand un travail déclare container: et services: : il crée un réseau Docker pour le travail, y démarre les services, et les rend joignables par leur nom. GitLab Runner, avec l'exécuteur Docker, procède de même avec l'alias déclaré dans services:.

Comment pytest-randomly mélange. Au démarrage, le greffon choisit une graine (ou prend celle de --randomly-seed), l'affiche, puis mélange les modules, les classes et les tests à l'aide de cette graine. Il réinitialise aussi random.seed() avant chaque test, pour qu'un test qui utilise des nombres aléatoires soit lui aussi rejouable. Même graine, même ordre : l'aléatoire est reproductible, ce qui en fait un outil de diagnostic et non une source d'instabilité.

Pourquoi la mesure de couverture dépend de l'étape. pytest-cov enregistre les lignes exécutées pendant cette exécution de pytest. Chaque étape produit donc sa propre mesure : 58 % pour les tests unitaires, 84 % pour l'intégration. Pour obtenir la couverture totale, les plateformes conservent les fichiers de données de chaque étape comme artefacts, puis les combinent (coverage combine) dans une étape finale.

Pièges courants

Lire le résumé au lieu du journal. Le pipeline n°8 affichait TypeError: 'NoneType', un symptôme ; la cause, UndefinedColumn, était plus haut dans le journal complet. Avant de conclure, lisez le journal complet de l'étape en échec, en partant de la première erreur.

Relancer jusqu'au vert. Les plateformes proposent un bouton « relancer », et des greffons (pytest-rerunfailures) relancent automatiquement les tests en échec. C'est utile pour ne pas bloquer une équipe, à condition que chaque relance réussie soit comptée comme une instabilité à corriger. Sinon, la relance automatique cache le problème jusqu'au jour où elle cache un vrai défaut.

Attendre avec sleep. sleep 10 avant de lancer les tests d'intégration est trop long sur un agent rapide et trop court sur un agent chargé. Attendez activement un signal, avec un délai maximal, comme ci/attendre_postgres.py.

Des tests qui touchent Internet. Un test qui appelle une API publique échoue quand cette API est lente, en maintenance, ou limite votre débit. Il n'est pas hermétique. Remplacez la dépendance par un double de test, ou démarrez-en une version locale comme service.

Un seuil de couverture comme objectif. --cov-fail-under=80 pousse à écrire des tests sans assertion pour franchir le seuil. Préférez exiger que la couverture ne baisse pas sur le code modifié, et regardez les lignes non couvertes lors de la revue.

Mettre l'étape chère en premier. Une étape de bout en bout de cinq minutes placée avant le lint fait attendre cinq minutes pour apprendre qu'une ligne est mal formatée.

Sécurité

  • Jamais de données de production dans un pipeline. La tentation est forte de « tester sur des vraies données » en restaurant une copie de la base de production dans la CI. Pour une application comme Signalements, qui contient les adresses et les messages d'habitants, ce serait un traitement de données personnelles hors de sa finalité au sens du RGPD, sur des agents et dans des journaux qui ne sont pas protégés comme la production. Utilisez des données synthétiques, créées par les tests eux-mêmes.
  • Les identifiants des services de test sont jetables, et doivent le rester. POSTGRES_PASSWORD=ci est acceptable parce que la base est neuve, vide, sur un réseau privé à l'exécution, et détruite à la fin. Le même mot de passe sur une base persistante ou partagée serait une faille.
  • Les vérifications de sécurité sont des vérifications comme les autres. L'analyse des dépendances vulnérables, la recherche de secrets dans le code et l'analyse statique de sécurité suivent les mêmes règles d'ordre et de budget. Ruff 0.16 active d'ailleurs par défaut une partie des règles de sécurité de Bandit (préfixe S). La leçon 5 en fait le tour.

En production

  • Paralléliser. Les étapes indépendantes (format, lint, types) peuvent s'exécuter en même temps sur des agents différents ; une longue suite de tests se répartit en tranches (sharding), par exemple avec pytest-xdist sur un agent ou une matrice de travaux sur plusieurs. Notre serveur exécute tout en séquence, les vraies plateformes non.
  • Ne lancer que ce qui est concerné. Dans un grand dépôt, on peut restreindre les étapes aux parties modifiées (filtres de chemins dans les déclencheurs, ou outils de construction qui connaissent le graphe des dépendances, comme Bazel). Il faut alors une exécution complète régulière, pour rattraper ce que le filtrage aurait manqué.
  • Mettre les tests instables en quarantaine. Google retire temporairement les tests instables du verdict bloquant, avec un ticket de correction, plutôt que de laisser toute l'équipe les subir. Mesurez le taux d'instabilité (relances réussies sur le même commit) et traitez-le comme une dette.
  • Réserver les suites longues à après la fusion. Tests de performance, de bout en bout complets, analyses lourdes : ils peuvent tourner après la fusion, ou chaque nuit, à condition que quelqu'un soit chargé de réagir à leur échec dans l'heure.

Exercices

1. Ordonner (niveau 100). Un pipeline contient cinq étapes : tests de bout en bout (6 min, échoue 5 % du temps), formatage (10 s, échoue 20 % du temps), tests unitaires (1 min 30, échoue 10 % du temps), construction de l'image (2 min, échoue 2 % du temps), analyse statique (30 s, échoue 15 % du temps). Proposez un ordre, et dites ce qu'un développeur attend, en moyenne, avant d'apprendre que son formatage est incorrect, dans votre ordre et dans l'ordre inverse.

Solution

Un bon ordre : formatage, analyse statique, tests unitaires, construction de l'image, bout en bout. On place d'abord ce qui est court et échoue souvent ; la construction de l'image passe avant les tests de bout en bout, qui en ont besoin. Dans cet ordre, une erreur de formatage est signalée après 10 secondes. Dans l'ordre inverse, il faut attendre que les quatre autres étapes aient réussi : 6 min + 2 min + 1 min 30 + 30 s + 10 s, soit 10 min 10 s. Les vraies plateformes permettent aussi d'exécuter formatage, analyse statique et tests unitaires en parallèle.

2. Diagnostiquer une instabilité (niveau 100). Un test d'intégration échoue environ une fois sur vingt avec ConnectionRefusedError. L'étape commence par docker compose up -d && sleep 5 && pytest. Quelle est la catégorie d'instabilité, et comment la corriger ?

Solution

C'est une attente asynchrone : sleep 5 suffit presque toujours, sauf quand l'agent est chargé et que le service met plus de cinq secondes à démarrer. On remplace l'attente fixe par une attente active du signal utile, avec un délai maximal : docker compose up -d --wait (qui attend que les vérifications de santé des services passent, cours Docker : les fondamentaux, leçon 11), ou un script comme ci/attendre_postgres.py.

3. Un test de fumée (niveau 100). Ajoutez au pipeline une étape fumee, après l'intégration, qui démarre l'application avec gunicorn, connectée au service db, et vérifie que GET /sante répond 200. N'utilisez pas d'attente fixe.

Solution

Un script ci/fumee.py qui interroge l'application jusqu'à ce qu'elle réponde :

"""Test de fumée : l'application démarrée répond-elle sur /sante ?"""

import sys
import time
import urllib.request

for _ in range(20):
    try:
        with urllib.request.urlopen(
            "http://127.0.0.1:8000/sante", timeout=2
        ) as reponse:
            print(reponse.status, reponse.read().decode())
            sys.exit(0)
    except OSError:
        time.sleep(0.5)
sys.exit("l'application ne répond pas après 10 secondes")

Et la ligne de .pipeline ; --daemon met gunicorn en arrière-plan dans le conteneur de l'étape :

fumee         python:3.14-slim   export DATABASE_URL=postgresql://postgres:ci@db/postgres && pip install --user --quiet -r requirements.txt && python -m gunicorn --bind 127.0.0.1:8000 --daemon app:app && python ci/fumee.py
$ git push -q ci main
remote: pipeline n°14 : main @ ...
...
remote:   OK     integration (5 s)
remote:   OK     fumee (2 s)
$ grep -v WARNING serveur-ci/travaux/14/journaux/fumee.log | grep -v Consider
200 {"etat":"ok"}

Une première version avec sleep 2 à la place du script durait 5 secondes : l'attente active est à la fois plus fiable et plus rapide. Attention, le formatage s'applique aussi aux scripts de CI : la première poussée de ci/fumee.py, non formaté, a été refusée par l'étape format.

Récapitulatif

  • Un pipeline doit être rapide (sinon il est contourné) et fiable (sinon il est ignoré).
  • Les vérifications forment une pyramide : beaucoup de rapides et précises, peu de lentes et globales. Elles ne se remplacent pas : l'analyse statique ne connaît pas le schéma SQL, les tests unitaires ne connaissent pas PostgreSQL.
  • On ordonne les étapes pour échouer tôt : court et souvent rouge d'abord. L'étape de commit tient en quelques minutes.
  • Les tests d'intégration utilisent des services éphémères, propres à chaque exécution, sur un réseau privé, nettoyés quoi qu'il arrive.
  • La couverture montre le code jamais exécuté par un test (42 % de Signalements avant cette leçon) ; elle ne mesure pas la qualité des tests.
  • Un test instable est un défaut à corriger : attente fixe, concurrence ou dépendance à l'ordre en sont les causes principales. L'ordre aléatoire avec une graine affichée les révèle et les rend rejouables.

Pour aller plus loin

  • The Practical Test Pyramid de Ham Vocke, pour une discussion complète des niveaux de tests et des doubles de test.
  • Eradicating Non-Determinism in Tests de Martin Fowler, pour les autres causes d'instabilité (temps, ressources distantes, fuites entre tests) et leurs remèdes.
  • La leçon suivante, qui prolonge le pipeline au-delà des tests : construire un artefact une fois, et le promouvoir jusqu'à la production.
Voir ma constellation →

Sources