Vous avez 90% de code coverage et vous avez quand même reçu une alerte à 2 h du matin.

Les tests unitaires passaient. Le CI était vert. Le bug a quand même atteint la production. Le coverage n’a pas menti, mais il n’a pas dit la vérité non plus. Il a mesuré quelles lignes s’exécutaient, pas quels comportements étaient réellement vérifiés.

La plupart des équipes l’apprennent à leurs dépens. Elles écrivent des centaines de tests unitaires, regardent le badge de coverage passer au vert, et supposent que la forteresse est sécurisée. La forteresse a des murs. Elle n’a juste pas de toit.

Les tests unitaires ne testent que ce que vous imaginez pouvoir mal tourner

Les tests unitaires valident vos hypothèses sur votre code. Le problème, c’est que les bugs se fichent de vos hypothèses.

Prenons une simple fonction de calcul de prix :

def calculate_total(items, tax_rate):
    subtotal = sum(item["price"] * item["quantity"] for item in items)
    tax = subtotal * tax_rate
    return round(subtotal + tax, 2)

Une suite de tests unitaires typique semble solide :

def test_calculate_total_with_tax():
    items = [{"price": 10.00, "quantity": 2}]
    assert calculate_total(items, 0.08) == 21.60

def test_calculate_total_empty_cart():
    assert calculate_total([], 0.08) == 0.00

Les deux passent. Le coverage est de 100%. La fonction est mise en production.

Puis un client au Japon passe commande avec trois articles à ¥100, ¥100 et ¥100. Le taux de taxe est de 0,10. Le total attendu est ¥330. La fonction retourne ¥330,00. Ça va.

Un client en Suisse achète un article à CHF 12,35 avec une TVA de 7,7%. Attendu : CHF 13,30. Réel : CHF 13,30. Toujours bon.

Puis un client achète deux articles à $0,01 chacun en Oregon, où le taux de taxe est de 0,0. Attendu : $0,02. Réel : $0,02. Pass.

Le bug apparaît quand un client dans une juridiction avec un taux de taxe à None (parce que le service de taxe a retourné un null pour un code postal non reconnu) essaie de passer commande. La fonction multiplie subtotal * None et lève une TypeError. Vos tests unitaires n’ont jamais passé None comme taux de taxe parce que vous avez supposé que ce serait toujours un float.

C’est la limitation fondamentale. Les tests unitaires exercent les chemins auxquels vous avez pensé tester. Les bugs vivent dans les chemins auxquels vous n’avez pas pensé.

Les quatre endroits où les tests unitaires ne peuvent pas aller

Integration Boundaries

Les tests unitaires remplacent les dépendances externes par des mocks. Les mocks sont polis. Ils font exactement ce que vous leur dites. Les vraies APIs ne sont pas polies.

Votre mock de base de données retourne des lignes en quelques millisecondes. La production les retourne en quelques secondes, ou timeout, ou retourne des lignes dupliquées à cause d’un lag de read replica dont vous ignoriez l’existence.

Votre mock de client HTTP retourne du JSON propre. Le vrai service retourne un 200 avec un body vide le mardi.

Les mocks testent votre code contre vos hypothèses sur les autres systèmes. La production teste votre code contre la réalité. Ce sont des suites de tests différentes avec des taux de réussite différents.

Stateful and Temporal Bugs

Les tests unitaires s’exécutent en isolation. Chaque test part d’un état frais. La production est un processus de longue durée où l’état s’accumule, fuit et interagit avec lui-même.

Un cache mémoire qui évince des entrées sous charge. Un connection pool qui s’épuise après 10 000 requêtes. Une comparaison de timestamps qui échoue quand le test traverse une frontière de changement d’heure. Ces bugs nécessitent du temps, du volume ou une séquence pour se manifester. Les tests unitaires n’ont aucun de ces éléments.

Concurrency and Race Conditions

Deux utilisateurs mettent à jour le même enregistrement simultanément. Une requête lit un solde, une autre le débite, la première écrit en retour la valeur périmée. L’argent disparaît. Vos tests unitaires s’exécutent séquentiellement dans un seul thread. Ils ne peuvent pas attraper ça.

Vous pouvez écrire des tests unitaires pour des primitives de verrouillage individuelles. Vous ne pouvez pas écrire un test unitaire qui prouve que tout votre système est exempt de race conditions. L’espace d’état est trop grand et le timing trop non déterministe.

The Environment Itself

Vos tests tournent sur Ubuntu 22.04 avec Python 3.11, 4 Go de RAM et aucune règle de firewall. La production tourne sur Alpine Linux avec Python 3.11, 512 Mo de RAM et un security group qui coupe les connexions TCP inactives après 60 secondes.

Le module socket se comporte différemment. Les limites de mmap sont plus basses. Les paramètres de locale font que strftime formate les dates d’une manière que votre parser n’attend pas. Ce ne sont pas des bugs de code. Ce sont des bugs de contexte. Les tests unitaires n’ont pas de contexte.

Pourquoi le pourcentage de coverage induit en erreur

Les outils de coverage mesurent l’exécution des lignes, pas la qualité des assertions. Un test peut exécuter chaque ligne d’une fonction et ne rien vérifier de significatif.

def test_poor_coverage_quality():
    result = calculate_total([{"price": 1.0, "quantity": 1}], 0.0)
    # Executed 100% of lines. Verified almost nothing.
    assert result is not None

Ce test vous donne 100% de line coverage et zéro confiance. Beaucoup d’équipes optimisent la métrique parce qu’elle est facile à mesurer. La confiance est difficile à mesurer. Alors elles mesurent le coverage à la place et espèrent que les deux sont corrélés.

Ils ne le sont pas.

Quoi tester à la place (ou en complément)

Ce n’est pas un argument contre les tests unitaires. Les tests unitaires sont rapides, déterministes et excellents pour vérifier la logique algorithmique. Ils sont juste incomplets.

Voici ce qui comble les lacunes sans transformer votre pipeline CI en un passif de 45 minutes.

Test at System Boundaries, Not Just Internals

Au lieu de mocker la base de données, écrivez des tests qui touchent une vraie base de données de test. Ceux-ci sont plus lents, donc exécutez-les de manière sélective. Mais ils attrapent le décalage entre vos requêtes ORM et le comportement réel du query planner.

Au lieu de mocker le client HTTP, lancez le downstream service dans un container. Ça attrape le schema drift, le comportement de timeout et la retry logic qui ne se déclenche que sur de vraies défaillances de connexion.

Add Contract Tests for External Services

Si vous ne pouvez pas faire tourner la vraie dépendance dans le CI, utilisez des contract tests. Ceux-ci vérifient que vos attentes en tant que consumer correspondent au schéma réel de l’API du provider.

Des outils comme Pact enregistrent les interactions entre votre service et ses dépendances. Si le provider change un type de champ ou supprime un endpoint, le contract test échoue avant que le code ne soit déployé. Ce n’est pas aussi bon que l’integration testing, mais c’est bien mieux que d’espérer que vos mocks sont exacts.

Use Property-Based Testing for Edge Cases

Les outils de property-based testing comme Hypothesis (Python) ou fast-check (JavaScript) génèrent des milliers d’inputs aléatoires et vérifient que vos invariants sont respectés.

from hypothesis import given, strategies as st

@given(
    st.lists(st.fixed_dictionaries({
        "price": st.decimals(min_value=0, max_value=10000, places=2),
        "quantity": st.integers(min_value=0, max_value=1000)
    })),
    st.one_of(st.none(), st.decimals(min_value=0, max_value=1, places=4))
)
def test_calculate_total_invariants(items, tax_rate):
    if tax_rate is None:
        with pytest.raises(TypeError):
            calculate_total(items, tax_rate)
        return

    result = calculate_total(items, tax_rate)
    assert result >= 0
    assert result == round(result, 2)

Ce test aurait attrapé le bug du taux de taxe None sans que vous ayez à penser à écrire ce cas spécifique. Il génère des inputs auxquels vous n’auriez jamais pensé : des listes vides, des listes géantes, des prix à zéro, des decimals de précision maximale. Il trouve les limites de votre logique sans que vous ayez à les imaginer d’abord.

Monitor Production Like It’s a Test Environment

La suite de tests la plus honnête, c’est le trafic de production. Si vous ne pouvez pas attraper un bug avant qu’il ne soit déployé, attrapez-le avant qu’il ne fasse des dégâts.

Utilisez des feature flags pour déployer les changements sur 1% des utilisateurs d’abord. Surveillez les taux d’erreur, les percentiles de latence et les métriques business. Un test unitaire vous dit si le code se comporte comme attendu en isolation. Un monitor de production vous dit si le code se comporte comme attendu dans la réalité.

Mettez en place des alertes sur les anomalies, pas seulement sur les échecs durs. Une augmentation de 5% des erreurs 500 après un déploiement est souvent le seul signal qu’une race condition ou une fuite de ressources a commencé. Les tests unitaires ne vous montreront jamais ça.

The Honest Trade-off

Les tests unitaires sont bon marché, rapides et bons pour les boucles de feedback des développeurs. Les tests d’intégration sont coûteux, lents et bons pour attraper les bugs qui comptent.

Vous avez besoin des deux. Le piège, c’est de penser que 100% de coverage par des tests unitaires signifie que vous pouvez ignorer le reste. Ça signifie que vous avez testé les parties faciles en profondeur. Les parties difficiles, celles qui vous réveillent la nuit, vivent là où vos tests ne regardent pas.

Commencez par des tests unitaires pour la logique et les algorithmes. Ajoutez des tests d’intégration à chaque boundary du système. Utilisez le property-based testing pour trouver les inputs auxquels vous n’aviez pas pensé. Surveillez la production pour attraper ce que chaque test a manqué.

Le coverage est une vanity metric. La seule métrique qui compte, c’est si vous dormez toute la nuit.


Si vous essayez d’attraper les bugs que vos tests manquent, commencez par examiner vos données d’erreur. Sentry vous montre ce qui casse en production, avec les stack traces et le contexte que vos tests unitaires n’ont jamais eus.