Une fonction peut avoir 100 % de couverture de tests unitaires, et la page qui l’appelle peut quand même renvoyer une 500 en production. Ce n’est pas une contradiction. Les tests unitaires, d’intégration et end-to-end vérifient des choses différentes, et une suite chargée sur l’un et légère sur l’autre présente un trou précis et prévisible.

Ce que vérifient les tests unitaires, d’intégration et end-to-end

Prenez une fonctionnalité d’inscription : une fonction valide la saisie, écrit une ligne dans une base de données et envoie un email de bienvenue. Trois tests peuvent la couvrir, et chacun répond à une question différente.

NiveauQuestion à laquelle il répondCe qui est réel, ce qui est remplacé
UnitaireCette fonction fait-elle ce qu’elle doit faire ?Tout ce qui entoure la fonction est remplacé : pas de vraie base de données, pas de vrai réseau
IntégrationCette fonction et une dépendance réelle fonctionnent-elles ensemble ?La base de données est réelle ; les parties encore hors du périmètre testé (le serveur de messagerie) sont remplacées
End-to-endLa requête complète fonctionne-t-elle, vue de l’extérieur ?Rien n’est remplacé. Une vraie requête HTTP atteint un vrai processus en cours d’exécution

Un test unitaire vous dit que la validation du format d’email rejette ce qu’elle doit rejeter. Un test d’intégration vous dit que la ligne finit vraiment dans la base de données, avec les colonnes que vous attendez. Un test end-to-end vous dit qu’un client peut appeler l’endpoint et recevoir une 201. Aucun ne peut remplacer les autres.

Tester une fonctionnalité Flask aux trois niveaux

Voici la fonctionnalité : une fonction sign_up qui valide un email, crée un utilisateur et envoie un message de bienvenue.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
# app.py
import os
import smtplib
import sqlite3

from flask import Flask, jsonify, request


def get_db():
    db_path = os.environ.get("DB_PATH", "app.db")
    return sqlite3.connect(db_path)


def get_smtp_client():
    host = os.environ.get("SMTP_HOST", "localhost")
    port = int(os.environ.get("SMTP_PORT", "25"))
    return smtplib.SMTP(host, port)


def init_db(db):
    db.execute("CREATE TABLE IF NOT EXISTS users (email TEXT PRIMARY KEY, name TEXT)")
    db.commit()


def create_user(db, email, name):
    db.execute("INSERT INTO users (email, name) VALUES (?, ?)", (email, name))
    db.commit()


def send_welcome_email(smtp_client, to_address, name):
    smtp_client.sendmail(
        "[email protected]",
        to_address,
        f"Subject: Welcome\n\nHi {name}, thanks for signing up.",
    )


def sign_up(db, smtp_client, email, name):
    if not email or "@" not in email:
        raise ValueError("invalid email")
    create_user(db, email, name)
    send_welcome_email(smtp_client, email, name)
    return {"email": email, "name": name, "status": "created"}


app = Flask(__name__)


@app.post("/signup")
def signup_route():
    data = request.get_json()
    db = get_db()
    init_db(db)
    smtp_client = get_smtp_client()
    result = sign_up(db, smtp_client, data["email"], data["name"])
    smtp_client.quit()
    return jsonify(result), 201


@app.get("/users/<email>")
def get_user_route(email):
    db = get_db()
    row = db.execute("SELECT email, name FROM users WHERE email = ?", (email,)).fetchone()
    if row is None:
        return jsonify({"error": "not found"}), 404
    return jsonify({"email": row[0], "name": row[1]})

sign_up prend db et smtp_client en arguments plutôt que d’utiliser une connexion globale. Ce choix rend possibles les trois tests ci-dessous : chacun passe quelque chose de différent pour ces deux paramètres. get_db() et get_smtp_client() lisent leur cible depuis une variable d’environnement à chaque appel plutôt que de la coder en dur une fois pour toutes, ce qui permet au test end-to-end plus bas de pointer l’application en cours d’exécution vers une base de données jetable et un serveur de messagerie de test local sans toucher à app.py.

Test unitaire : remplacer les deux dépendances

Un test unitaire isole complètement sign_up. Ni la base de données ni le client SMTP ne sont réels : ce sont tous deux des objets Mock de la bibliothèque standard de Python.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# test_unit.py
from unittest.mock import Mock

import pytest

from app import sign_up


def test_sign_up_rejects_invalid_email():
    db, smtp_client = Mock(), Mock()
    with pytest.raises(ValueError):
        sign_up(db, smtp_client, "not-an-email", "Ada")
    db.execute.assert_not_called()


def test_sign_up_sends_welcome_email():
    db, smtp_client = Mock(), Mock()
    sign_up(db, smtp_client, "[email protected]", "Ada")
    smtp_client.sendmail.assert_called_once()
    to_address = smtp_client.sendmail.call_args.args[1]
    assert to_address == "[email protected]"

Deux types de doublures de test entrent en jeu ici, et les noms comptent parce qu’ils fixent des attentes différentes. db dans le premier test est proche d’un dummy : on le passe parce que la signature exige quelque chose, et le test se contente de vérifier qu’il n’est jamais touché. smtp_client dans le second test est un mock au sens strict. Le test définit l’attente (sendmail est appelé avec cette adresse) et échoue si la réalité ne correspond pas. C’est de la vérification de comportement : vous vérifiez qu’un appel a eu lieu, pas ce qu’il a renvoyé.

Ces tests s’exécutent en quelques millisecondes et ne touchent jamais le disque ni un socket. Ils ne peuvent pas non plus vous dire si INSERT INTO users correspond aux vraies colonnes de la table. db n’exécute jamais rien de réel, donc une faute de frappe dans le SQL passerait tous les tests unitaires que vous écrivez.

Test d’intégration : une vraie base de données, un faux serveur de messagerie

Un test d’intégration comble cet écart une dépendance à la fois. Ici la base de données est réelle, une connexion SQLite en mémoire avec le schéma réel, tandis que le serveur de messagerie reste remplacé. Cette fois c’est un fake : une petite implémentation qui fonctionne, plutôt qu’une doublure scriptée.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# test_integration.py
import sqlite3

from app import init_db, sign_up


class FakeSMTP:
    def __init__(self):
        self.sent = []

    def sendmail(self, from_addr, to_addr, message):
        self.sent.append((to_addr, message))


def test_sign_up_persists_user_and_records_email():
    db = sqlite3.connect(":memory:")
    init_db(db)
    smtp_client = FakeSMTP()

    sign_up(db, smtp_client, "[email protected]", "Grace")

    row = db.execute(
        "SELECT email, name FROM users WHERE email = ?", ("[email protected]",)
    ).fetchone()
    assert row == ("[email protected]", "Grace")
    assert smtp_client.sent[0][0] == "[email protected]"

FakeSMTP n’a pas d’attentes scriptées à l’avance comme le faisait smtp_client.sendmail.assert_called_once(). Il garde une liste réelle, bien que minuscule, de ce qui a été envoyé, et le test la lit après coup. C’est la différence entre un mock et un fake : à un mock, on dit ce qu’on attend avant que le code s’exécute ; un fake se comporte comme une version réduite de la vraie chose, et on l’inspecte après.

Ce test détecte un nom de colonne erroné ou une table manquante. Il ne détecte pas un bug dans le parsing des requêtes de Flask ni une mauvaise URL dans le décorateur de route, parce que rien ici ne passe par HTTP.

Test end-to-end : une vraie requête contre un processus en cours d’exécution

Un test end-to-end supprime les deux substitutions. Il démarre la vraie application Flask comme un processus séparé, lui envoie une vraie requête HTTP sur un vrai socket, et vérifie le résultat comme le ferait un client extérieur : en rappelant l’API, pas en fouillant dans la base de données.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# test_e2e.py
import multiprocessing
import os
import tempfile
import time

import requests
from aiosmtpd.controller import Controller

from app import app


class AcceptAllHandler:
    async def handle_DATA(self, server, session, envelope):
        return "250 Message accepted for delivery"


def run_server():
    app.run(port=5055)


def test_signup_end_to_end():
    db_fd, db_path = tempfile.mkstemp(suffix=".db")
    os.close(db_fd)
    os.environ["DB_PATH"] = db_path
    os.environ["SMTP_HOST"] = "127.0.0.1"
    os.environ["SMTP_PORT"] = "1025"

    mail_server = Controller(AcceptAllHandler(), hostname="127.0.0.1", port=1025)
    mail_server.start()

    server = multiprocessing.Process(target=run_server)
    server.start()
    time.sleep(0.5)  # laisser au serveur de dev de Flask le temps de se lier au port

    try:
        signup = requests.post(
            "http://127.0.0.1:5055/signup",
            json={"email": "[email protected]", "name": "Amelia"},
        )
        assert signup.status_code == 201

        lookup = requests.get("http://127.0.0.1:5055/users/[email protected]")
        assert lookup.json() == {"email": "[email protected]", "name": "Amelia"}
    finally:
        server.terminate()
        mail_server.stop()
        os.remove(db_path)

aiosmtpd remplace le collecteur de mails que vous feriez tourner dans un environnement de staging : quelque chose qui parle vraiment SMTP sur un socket, pour que le vrai appel smtplib.SMTP(...) de l’application puisse s’y connecter. Le test se crée aussi un fichier de base de données jetable plutôt que de réutiliser app.db : relancer ce test deux fois contre un fichier partagé échouerait la seconde fois sur la même PRIMARY KEY déjà occupée par [email protected], exactement le genre d’instabilité liée à un état résiduel dont l’article parle plus loin. Rien dans ce test ne sait que sign_up ou create_user existent. Il ne connaît que le contrat HTTP. Renommez une route ou changez la forme du JSON, et c’est ce test qui échoue, tandis que les tests unitaires et d’intégration ci-dessus continuent de passer.

Installez les dépendances avec pip install flask requests aiosmtpd pytest et lancez les trois avec pytest. Quand quelque chose casse, le fichier qui échoue vous dit quelle classe de bug vous avez. C’est ce qui justifie d’écrire plus d’un niveau de tests ; le pourcentage de couverture, lui, ne le fait pas.

Pyramide des tests vs trophée des tests

La forme classique d’une suite de tests est la pyramide des tests de Mike Cohn : une large base de tests unitaires rapides, une couche plus petite de tests d’intégration, et une pointe fine de tests end-to-end lents et coûteux. Ce qui compte, c’est le ratio, pas le nombre. La majorité de vos tests devrait être bon marché.

Le trophée des tests de Kent C. Dodds place le poids ailleurs. Son argument : les tests unitaires isolés sont faciles à écrire, mais la confiance qu’ils donnent ne reflète pas la façon dont le logiciel est réellement utilisé, donc une suite composée surtout de tests unitaires peut laisser passer de vrais bugs aux jointures. Le trophée place l’analyse statique (linting, vérification de types) à la base, une couche unitaire au-dessus, les tests d’intégration comme partie la plus large, et une fine couche end-to-end au sommet. test_sign_up_persists_user_and_records_email ci-dessus est le type de test que le trophée veut voir en plus grand nombre.

Pyramide (Cohn)Trophée (Dodds)
Couche la plus grandeUnitaireIntégration
Couche de baseTests unitairesAnalyse statique (lint, types)
Question directriceCombien ce test coûte-t-il à écrire et à exécuter ?À quel point ce test ressemble-t-il à un usage réel ?
Point faible viséSuites end-to-end lentes et fragilesTests unitaires qui passent alors que les jointures sont cassées

Ne suivez aucune des deux formes à la lettre. Elles s’accordent sur l’ordre : les tests end-to-end sont les plus coûteux et devraient être les moins nombreux, les tests unitaires sont les moins chers. Là où va l’essentiel de votre effort dépend de l’historique de bugs de votre projet. Si la plupart des bugs sont des composants qui se comportent mal isolément, penchez vers la pyramide. Si la plupart sont des composants qui ne s’accordent pas sur la façon de se parler, penchez vers le trophée.

Pourquoi les tests end-to-end deviennent instables

Une suite avec une pile croissante de tests end-to-end finit par se forger une réputation : vous relancez celui qui échoue, et il passe. C’est de l’instabilité, et elle vient de la même propriété qui rend les tests end-to-end précieux. Ils touchent une vraie infrastructure.

Les causes habituelles sont un sleep() fixe suffisant sur votre portable mais trop court sur un runner CI chargé, des données de test laissées par une exécution précédente qui entrent en collision avec la suivante, un appel réseau vers quelque chose hors de votre contrôle, et un état partagé entre tests qui n’apparaît que lorsqu’ils s’exécutent dans un ordre différent ou en parallèle. Le time.sleep(0.5) du test ci-dessus relève du premier type ; le fichier temporaire que ce même test crée pour DB_PATH est une correction directe pour le second. Aucun de ces cas n’est un bug dans la fonctionnalité. Ce sont des bugs dans la façon dont le test gère l’infrastructure réelle, un problème que les tests unitaires n’ont jamais.

Relancer jusqu’à obtenir un résultat vert n’est pas la solution ; cela cache le prochain vrai échec dans le bruit. Gardez la couche end-to-end fine en couvrant les parcours critiques, pas chaque branche. Donnez à chaque test ses propres données plutôt que des fixtures partagées. Remplacez les attentes fixes par une vérification répétée de la condition dont vous avez réellement besoin, comme le port qui accepte les connexions. Le temps de CI est l’autre coût : si vous avez déjà accéléré le pipeline en mettant en cache les dépendances dans GitHub Actions, une couche end-to-end qui a besoin de trois relances pour passer au vert redonne une bonne partie de ce temps.

Quel test écrire ensuite

Si un bug vient d’échapper à vos tests et d’atteindre la production, demandez-vous quel niveau l’aurait attrapé avant d’ajouter le moindre test. Un mauvais calcul ou un cas limite non géré dans une fonction appelle un test unitaire. Deux parties de votre propre code qui ne s’accordent pas sur une forme ou un contrat, comme l’API qui renvoie un champ que le frontend n’attend pas ou une requête qui suppose une colonne qui a été renommée, appellent un test d’intégration. Une fonctionnalité qui passe tous les tests mais échoue pour un utilisateur réel manque en général de couverture end-to-end sur le parcours que cet utilisateur a suivi.

Écrivez d’abord le test unitaire. C’est le signal le moins cher et le plus rapide pour vous dire quand vous avez cassé la logique. Ajoutez un test d’intégration quand deux de vos propres composants doivent prouver qu’ils s’accordent. Réservez les tests end-to-end à la poignée de parcours où « ça marche » ne veut dire quelque chose que vu de l’extérieur : inscription, connexion, paiement, réinitialisation du mot de passe.

Gardez cette séparation et vous obtenez une suite dont les échecs veulent dire quelque chose. Cela compte au-delà de la CI. Git bisect avec git bisect run a besoin d’une commande de test qui échoue seulement quand le code est vraiment cassé, et un test end-to-end instable l’enverra sur le mauvais commit.

Articles associés