Introduire le TDD dans une équipe qui n'en a jamais fait
On ne passe pas de 0 à 95 % de couverture en demandant des tests. On commence par figer le comportement existant — et l'équipe se convertit le jour où le filet attrape quelque chose.
Une équipe qui n'écrit pas de tests n'est pas une équipe négligente. C'est presque toujours une équipe dont le code est difficile à tester, et qui a rationnellement conclu que le coût dépassait le bénéfice. Demander des tests sans traiter cette cause produit des tests d'accompagnement : écrits après, sur ce qui était facile, et qui ne protègent rien.
Pourquoi « écrivez des tests » ne marche pas
Sur un moteur de calcul existant, le premier obstacle est structurel. Le code lit sa configuration dans un fichier, ouvre une session, écrit dans une base : il n'y a pas de fonction pure à appeler. Écrire un test suppose d'abord de refactorer, et refactorer sans test est précisément ce que personne ne veut faire.
C'est un problème d'amorçage, pas de discipline. Tant qu'on le traite comme un problème de discipline, on obtient de la culpabilité et pas de la couverture.
Le second obstacle est la temporalité. Le bénéfice d'un test arrive des mois plus tard, au moment d'une modification. Le coût est immédiat. Une équipe sous pression d'échéance choisit rationnellement le court terme — jusqu'à ce qu'elle constate elle-même le bénéfice.
Commencer par les tests de caractérisation
Le déblocage consiste à inverser l'ordre habituel. On n'écrit pas des tests de ce que le code devrait faire — personne ne le sait avec certitude sur un moteur ancien. On écrit des tests de ce qu'il fait, en l'état, juste ou faux.
Concrètement, sur une journée de production de référence : on capture les entrées, on capture les sorties, on en fait des fichiers témoins, et on écrit un test qui rejoue et compare.
class MoteurCaracterisation extends AnyFunSuite with SparkSessionTest {
test("le calcul reproduit la sortie de référence du 12 mars") {
val entree = lireJeu("temoins/2026-03-12/entree.parquet")
val reference = lireJeu("temoins/2026-03-12/sortie.parquet")
val obtenu = Moteur.calculer(entree, ParametresParDefaut)
// comparaison insensible à l'ordre, tolérante sur les flottants
assertDataFrameEquals(obtenu, reference, tolerance = 1e-9)
}
}
Ce test a trois propriétés que n'a aucun test écrit « proprement » au départ. Il ne demande aucun refactoring préalable. Il documente le comportement réel, y compris ses bizarreries. Et il autorise immédiatement le refactoring, puisqu'on saura si on a changé quelque chose.
Le point de vigilance : un test de caractérisation fige aussi les bugs. C'est voulu. Quand on corrige un bug, on met à jour le témoin dans le même commit, avec une explication. La question « pourquoi cette valeur a changé » a alors une réponse écrite quelque part.
Le cas particulier des jobs Spark
Tester du Spark a la réputation d'être lent, et c'est vrai quand on teste au mauvais niveau. La règle qui change tout : séparer la transformation de la session. Une fonction qui prend des DataFrames et rend un DataFrame se teste en local en quelques centaines de millisecondes ; une fonction qui ouvre elle-même sa session et lit ses chemins ne se teste pas du tout.
// difficile à tester : la fonction va chercher ses entrées et sa configuration
def calculer(): Unit = {
val spark = SparkSession.builder().getOrCreate()
val df = spark.read.parquet(Config.chemin)
df.filter(...).write.mode("overwrite").parquet(Config.sortie)
}
// testable : les entrées et les paramètres arrivent par la signature
def calculer(entree: DataFrame, params: Parametres): DataFrame =
entree.filter(col("statut") === params.statut)
.withColumn("montant_net", col("montant") - col("frais"))
Une session Spark locale, partagée entre les tests d'une suite, réduit encore le coût :
trait SparkSessionTest extends BeforeAndAfterAll { self: Suite =>
lazy val spark: SparkSession = SparkSession.builder()
.master("local[2]")
.config("spark.sql.shuffle.partitions", "2") // décisif : 200 par défaut = lenteur
.config("spark.ui.enabled", "false")
.getOrCreate()
}
Le passage de shuffle.partitions à 2 est le réglage qui fait passer une suite de
plusieurs minutes à quelques secondes. C'est souvent lui, à lui seul, qui réconcilie une équipe avec
l'idée de lancer les tests avant de pousser.
Rendre la règle mécanique plutôt que morale
Une fois le filet en place, la couverture progresse si elle est adossée à un mécanisme, pas à un rappel en réunion. Trois leviers, par ordre d'efficacité.
- Un seuil sur le code nouveau, pas sur le code global. Exiger 80 % sur l'ensemble d'un moteur ancien est décourageant et sera contourné. Exiger 80 % sur les lignes modifiées par la demande de fusion est atteignable, et fait monter la couverture globale mécaniquement.
- Un test obligatoire avec chaque correction de bug. C'est la règle la plus facile à accepter, parce qu'elle est évidemment rationnelle : on ne veut pas revoir ce bug.
- La qualité en échec bloquant, pas en avertissement. Un rapport qu'on peut ignorer est ignoré. Un pipeline rouge est traité.
# quality gate sur le code nouveau uniquement
sonar.qualitygate.wait=true
# nouvelle couverture minimale : 80 % des lignes ajoutées ou modifiées
Le basculement culturel, lui, ne vient d'aucune de ces règles. Il vient du jour où le filet attrape quelque chose : un test de caractérisation qui échoue sur une modification qu'on croyait sans risque. Ce jour-là, l'équipe cesse de voir les tests comme une taxe. Il faut simplement s'assurer que ça se produise tôt — et le raconter quand ça arrive.
Ce que la couverture ne dit pas
Un chiffre de couverture élevé est un indicateur, pas une garantie. Il dit quelles lignes ont été exécutées, pas si le résultat a été vérifié. On peut atteindre 95 % avec des tests qui n'assertent rien.
Les trois questions que je pose à la place, et qui sont plus révélatrices :
- Combien de temps prend la suite complète ? Au-delà de dix minutes, elle ne sera pas lancée localement, donc elle ne protège plus pendant l'écriture.
- Quand un test échoue, combien de temps faut-il pour comprendre pourquoi ? Un test dont l'échec n'est pas diagnosticable sera désactivé à la première urgence.
- Y a-t-il un test qui échouerait si on inversait une condition dans le calcul métier central ? C'est la seule vraie question — et on peut y répondre en une minute, en inversant la condition.
La couverture est un moyen d'obtenir un budget et de suivre une trajectoire. Ce qui protège la production, c'est le contenu des assertions.
Chafiq Madkour est Tech Lead et Senior Data Engineer. Dix ans sur des plateformes data critiques en banque, en assurance et en finance de marché — déclaratifs réglementaires, détection de fraude, moteurs de calcul de risque.