On écrit plus de tests avec l'IA. Mais est-ce qu'on les comprend encore ?
L'IA peut générer une suite de tests en quelques secondes. Le vrai défi devient alors de savoir quels comportements méritent d'être protégés et si l'équipe comprend encore les tests qu'elle devra maintenir.
Les développeurs savent généralement à quoi servent les tests.
Ils permettent de vérifier qu'un comportement fonctionne, de détecter des régressions et de modifier une application avec davantage de confiance.
Pourtant, dans la réalité d'un projet, deux phrases reviennent facilement :
"On n'a pas le temps d'écrire les tests."
Ou, plus simplement :
"Je n'aime pas faire les tests."
La fonctionnalité, elle, doit sortir.
Elle est visible.
Le métier peut la tester, le chef de projet peut la montrer et l'utilisateur peut s'en servir.
Le test protège surtout contre un problème qui n'est pas encore arrivé.
Son coût est immédiat.
Sa valeur est souvent différée.
Puis l'IA générative est arrivée.
Aujourd'hui, donner une classe à un assistant et demander :
Écris les tests unitaires de cette classe.
peut produire très rapidement plusieurs dizaines de tests.
Une partie du problème semble donc avoir disparu : le coût d'écriture.
Mais cela déplace le goulot d'étranglement.
Le problème n'est plus seulement :
Combien de temps faut-il pour écrire ces tests ?
Il devient :
Quels tests méritent réellement d'exister, que protègent-ils et qui sera capable de les comprendre lorsqu'ils casseront dans six mois ?
Cent tests générés automatiquement ne créent pas nécessairement plus de confiance que quinze tests soigneusement choisis.
Tout dépend de ce qu'ils protègent.
Écrire un test et comprendre sa valeur sont deux choses différentes
Une suite verte procure une sensation rassurante.
142 passed
Tout fonctionne.
En apparence.
Mais ce résultat ne répond pas à une question essentielle :
Est-ce que ces 142 tests vérifient réellement ce qui compte ?
Une IA peut parfaitement produire un test techniquement valide comme celui-ci :
def test_damage_is_created(self):
damage = Damage.objects.create(
inventory=self.inventory,
species=self.species,
plot_number=1,
)
self.assertIsNotNone(damage.id)
Le test passe.
Mais quelle règle de l'application protège-t-il réellement ?
Il vérifie principalement que l'objet peut être enregistré.
Ce n'est pas forcément inutile, mais sa valeur est faible si aucune logique spécifique ne se trouve derrière cette création.
À côté de cela, une règle beaucoup plus importante peut rester sans protection :
Pour un inventaire, une placette, une typologie et une essence donnés, il ne doit pas pouvoir exister deux dégâts actifs représentant la même information.
Les deux tests peuvent pourtant compter de la même manière dans certaines métriques.
C'est là que le nombre de tests commence à devenir trompeur.
La vraie question n'est pas seulement :
Combien de lignes de code avons-nous exécutées pendant les tests ?
Mais :
Quelles hypothèses importantes de notre système avons-nous réellement vérifiées ?
Un test peut être une spécification exécutable
Il existe une autre manière de regarder les tests.
Un bon test ne sert pas uniquement à détecter un bug après l'écriture du code.
Il peut aussi expliquer ce que le système est censé faire.
Imaginons ce nom :
def test_deleted_damage_does_not_prevent_creating_new_active_damage():
...
Même sans lire l'implémentation, il raconte déjà une partie du domaine.
Il nous apprend :
- qu'un dégât peut être supprimé ;
- que sa suppression n'est probablement pas physique ;
- qu'un élément supprimé ne participe plus à certaines règles ;
- qu'un nouvel élément actif équivalent peut ensuite être créé.
Pour un développeur qui revient sur le projet plusieurs mois plus tard, ce test contient davantage d'informations qu'un simple :
def test_create_damage():
...
Le nom du test exprime une règle.
L'assertion doit ensuite la démontrer.
C'est une propriété particulièrement intéressante des tests : ils peuvent devenir une forme de documentation exécutable du comportement attendu.
Et c'est aussi là que l'IA peut être utilisée autrement.
Avant de lui demander d'écrire du code, on peut lui demander d'aider à expliciter les scénarios.
Quand "supprimer" ne veut plus dire supprimer
Les régressions les plus intéressantes apparaissent souvent lorsqu'une hypothèse ancienne du système change.
Prenons un cas concret.
Initialement, supprimer un dégât pouvait signifier supprimer réellement sa ligne de la base de données :
damage.delete()
Puis une contrainte offline/online apparaît.
La synchronisation change la signification de l'opération.
Lorsqu'une donnée est supprimée hors connexion, le système doit pouvoir transmettre ultérieurement cette information aux autres systèmes.
Faire disparaître immédiatement toute trace de la ligne n'est donc plus toujours suffisant.
Une solution consiste à introduire une suppression logique :
is_deleted = models.BooleanField(default=False)
deleted_at = models.DateTimeField(null=True, blank=True)
Le mot "supprimé" vient alors de changer de sens.
Avant :
supprimé
=
la ligne n'existe plus
Après :
supprimé
=
la ligne existe toujours
mais ne doit plus être considérée comme active
La différence paraît petite.
Elle traverse pourtant potentiellement une grande partie de l'application.
Une requête auparavant correcte :
Damage.objects.filter(
inventory=inventory,
)
peut soudainement retourner des données qui ne devraient plus participer au comportement fonctionnel.
Il faut désormais peut-être écrire :
Damage.objects.filter(
inventory=inventory,
is_deleted=False,
)
Et le problème ne se limite pas à l'affichage.
Une ligne supprimée peut accidentellement continuer à participer :
- à un calcul ;
- à une agrégation ;
- à une règle d'unicité ;
- à une validation ;
- à un export ;
- à une synchronisation suivante.
Le logiciel peut continuer à fonctionner sans produire d'exception.
Il renvoie simplement une information fausse.
C'est précisément le type de régression pour lequel quelques tests bien choisis valent beaucoup.
Tester l'hypothèse qui vient de changer
Après une modification structurelle comme le soft delete, il serait facile de demander à une IA :
Génère tous les tests possibles pour le modèle Damage.
Elle en produira probablement beaucoup.
Mais les premiers scénarios que je voudrais protéger seraient plutôt :
def test_deleted_damage_is_not_displayed():
...
def test_deleted_damage_is_excluded_from_calculation():
...
def test_deleted_damage_does_not_block_new_active_damage():
...
def test_two_identical_active_damages_cannot_coexist():
...
Ces tests ciblent directement les hypothèses qui ont changé.
Ils ne cherchent pas principalement à vérifier Django.
Ils cherchent à vérifier notre application.
C'est une distinction que je trouve importante :
Un test utile protège un comportement, une règle ou un invariant que nous avons une raison de ne pas vouloir perdre.
Un invariant important ne devrait parfois pas dépendre uniquement des tests
Prenons justement la règle d'unicité.
Dans cet exemple simplifié, un dégât actif est identifié par la combinaison :
inventaire
+ numéro de placette
+ typologie
+ essence
Deux enregistrements historiques marqués :
is_deleted=True
peuvent éventuellement continuer à exister.
En revanche, deux lignes actives correspondant exactement à la même clé métier ne doivent pas pouvoir coexister.
Un test doit vérifier ce comportement.
Mais une autre question apparaît :
Si cet invariant est indispensable à l'intégrité des données, pourquoi la base de données accepterait-elle un état qui le viole ?
Avec Django, sur un moteur de base de données prenant en charge ce type de contrainte conditionnelle, il est possible d'exprimer l'intention avec UniqueConstraint :
from django.db import models
from django.db.models import Q
class Damage(models.Model):
# ...
class Meta:
constraints = [
models.UniqueConstraint(
fields=[
"inventory",
"plot_number",
"typology",
"species",
],
condition=Q(is_deleted=False),
name="unique_active_damage_per_business_key",
),
]
Le test et la contrainte n'ont alors pas le même rôle.
La base protège une propriété :
cet état invalide ne doit pas pouvoir être enregistré.
Le test décrit un comportement attendu :
voici la règle que l'application doit continuer à respecter.
Les deux mécanismes se complètent.
Et ce raisonnement dépasse Django.
Lorsqu'une règle représente réellement un invariant de données, la meilleure protection n'est pas toujours uniquement une assertion dans une suite de tests.
Le vrai coût d'un test peut apparaître plusieurs semaines plus tard
L'IA réduit très efficacement une partie du coût :
l'écriture initiale.
Elle peut accélérer :
- la création des fixtures ;
- les factories ;
- les mocks ;
- les appels au client de test ;
- les assertions répétitives ;
- la génération de cas limites ;
- les scénarios très proches les uns des autres.
C'est utile.
Mais quelques semaines passent.
Une règle métier change.
Une colonne devient obligatoire.
Un workflow évolue.
Puis la suite est relancée :
18 failed, 124 passed
Le coût qui avait disparu revient sous une autre forme.
Il faut comprendre les 18 tests rouges.
Pourquoi cette valeur était-elle attendue ?
Cette assertion représente-t-elle une règle métier ?
Est-ce le comportement qui a régressé ?
Ou le comportement attendu a-t-il volontairement changé ?
Le test vérifie-t-il une sortie observable ?
Ou reproduit-il simplement la structure interne de l'ancienne implémentation ?
Et si ces tests ont été générés puis ajoutés sans véritable relecture, le développeur doit faire ce travail de compréhension au pire moment : lorsque quelque chose est déjà cassé.
L'IA peut diminuer le coût d'écriture d'un test sans diminuer son coût de compréhension ni son coût de maintenance.
Elle peut même augmenter la dette de tests si la facilité de génération nous pousse à conserver davantage de scénarios que l'équipe n'est capable d'en comprendre.
Un test rouge n'est pas forcément un problème
Il faut cependant éviter l'extrême inverse.
Une suite qui casse après une évolution n'est pas nécessairement une mauvaise suite.
Imaginons qu'une règle passe volontairement de :
A produit B
à :
A produit C
Le test qui attend encore B doit échouer.
C'est exactement son travail.
Il force l'équipe à reconnaître explicitement qu'une ancienne attente n'est plus valable.
La friction est utile.
Le signal devient plus inquiétant lorsque la suite casse massivement après un changement qui ne devrait modifier aucun comportement observable.
Par exemple, on déplace une logique d'une fonction à une autre.
Le résultat est identique.
Mais vingt tests échouent uniquement parce qu'ils vérifient quels appels internes sont effectués.
Un test de ce type peut parfois être pertinent :
mock_service.assert_called_once_with(...)
Mais il crée aussi un couplage avec l'implémentation.
Si la règle importante est :
pour ces données, le total final doit être 42,
alors un test sur le résultat peut offrir davantage de liberté de refactoring qu'un test décrivant chaque appel intermédiaire.
La bonne question reste donc :
Quelle propriété est-ce que je veux protéger ?
Le comportement ?
Une interaction nécessaire ?
Une contrainte d'intégration ?
Ou simplement la manière dont le code est écrit aujourd'hui ?
Les tests peuvent eux aussi devenir de la dette technique
On parle souvent de dette technique à propos du code de production.
Moins souvent à propos des tests.
Pourtant, une suite de tests peut accumuler les mêmes problèmes :
- duplication ;
- fixtures incompréhensibles ;
- mocks imbriqués ;
- scénarios presque identiques ;
- assertions sans intention claire ;
- couplage très fort à l'implémentation ;
- tests instables ;
- temps d'exécution disproportionné ;
- cas historiques que plus personne ne comprend.
Une suite générée massivement par IA peut accélérer ce phénomène.
Non pas parce que les tests générés sont nécessairement mauvais.
Mais parce que leur coût de production devient suffisamment faible pour que nous soyons moins sélectifs.
Avant, écrire 50 tests demandait du temps.
Cette contrainte obligeait indirectement à choisir.
Maintenant, générer 50 tests peut demander quelques minutes.
La rareté s'est déplacée.
Ce qui manque n'est plus nécessairement le temps de taper le code.
C'est le temps de :
- lire ;
- challenger ;
- comprendre ;
- supprimer ;
- consolider ;
- maintenir.
Et si le métier n'est pas encore stabilisé ?
Cette question est particulièrement importante dans les applications métier.
Pourquoi écrire une batterie de tests très détaillée aujourd'hui si la règle risque d'être redéfinie dans deux semaines ?
La question est légitime.
La réponse ne devrait probablement être ni :
tout tester immédiatement,
ni :
attendre que toutes les règles soient définitivement stabilisées.
Certaines applications métier ne sont jamais réellement "terminées".
Il est plus utile de distinguer le niveau de certitude et le niveau de risque.
Un comportement encore incertain et peu risqué
Le métier hésite entre plusieurs workflows.
L'écran est encore expérimental.
Une valeur ou une présentation risque de changer rapidement.
Écrire immédiatement beaucoup de tests extrêmement détaillés peut créer du travail de maintenance sans apporter beaucoup de sécurité.
Un invariant déjà connu
D'autres règles sont beaucoup plus stables.
Par exemple :
un élément supprimé logiquement ne doit pas participer au calcul.
Ou :
deux dégâts actifs identiques ne doivent pas coexister.
Ces comportements méritent davantage d'être protégés.
Une opération difficile à réparer
Il existe aussi des scénarios dont le métier peut encore évoluer, mais dont une erreur serait coûteuse.
Une opération sur un FormSet peut par exemple créer, modifier et supprimer plusieurs lignes lors de la même action.
Si elle gère en plus une règle d'unicité, une erreur peut laisser plusieurs données dans un état incohérent.
Dans ce cas, quelques tests d'intégration deviennent rapidement intéressants.
On peut vouloir vérifier par exemple :
modifier plusieurs lignes
→ toutes les modifications attendues sont enregistrées
supprimer logiquement une ligne
→ elle ne participe plus à l'unicité active
créer un doublon actif
→ l'opération est refusée
une opération invalide
→ ne laisse pas une partie des données dans un état incohérent
Même si toute l'interface métier n'est pas encore figée, ces scénarios protègent des propriétés dont la violation coûterait cher.
Quand le temps manque, tester selon le risque
Dire qu'il faut "tout tester" est facile lorsqu'on ne tient pas compte du contexte.
Dans une petite équipe sur un projet déjà en retard, chaque heure investie dans les tests est aussi une heure qui n'est pas utilisée immédiatement pour livrer une fonctionnalité.
Ce constat n'est pas un argument contre les tests.
C'est une contrainte d'ingénierie.
La question devient alors plus intéressante :
Si je n'ai le temps d'ajouter que cinq tests aujourd'hui, lesquels réduisent le plus mon risque ?
Je regarderais en priorité :
- les règles dont la violation produit des données incorrectes ;
- les contraintes d'intégrité ;
- les calculs importants ;
- les permissions ;
- les opérations modifiant plusieurs objets ;
- les synchronisations ;
- les comportements ayant déjà provoqué une régression ;
- les parties critiques qui changent régulièrement.
À l'inverse, je serais moins pressé de tester du code essentiellement fourni par le framework lorsqu'aucune logique particulière n'a été ajoutée.
Tous les tests ont un coût.
Tous n'ont pas la même valeur.
La couverture aide, mais elle ne mesure pas la confiance
La couverture de code reste un indicateur utile.
Une zone critique qui n'est jamais exécutée pendant la suite de tests mérite probablement qu'on s'y intéresse.
Mais transformer le pourcentage de couverture en objectif peut produire des comportements étranges.
On peut faire passer une fonction dans tous ses embranchements sans réellement vérifier que ses résultats sont corrects.
À l'inverse, quelques tests d'intégration bien choisis peuvent sécuriser un workflow métier important tout en laissant certaines lignes techniques moins directement couvertes.
Considérons deux suites.
Suite A
100 tests
95 % de couverture
générés en grande partie automatiquement
peu relus
Suite B
15 tests
couverture plus faible
centrés sur des invariants métier
relus et compris
Si je dois reprendre le projet demain sans connaître ces tests, la seconde suite peut initialement m'apporter davantage de confiance.
Pas parce que quinze tests seraient meilleurs que cent.
L'objectif pourra très bien être d'en avoir ensuite 100, 500 ou plusieurs milliers.
Une application mature et critique peut légitimement nécessiter une suite très importante.
Le point est différent :
La quantité devient utile lorsqu'elle s'appuie sur une base de tests dont la raison d'être est comprise.
Les quinze premiers tests ne constituent pas un objectif final.
Ils constituent un socle.
Ce que l'IA change réellement
Avant l'IA générative, une partie importante du coût des tests était mécanique.
Créer plusieurs objets.
Écrire les mêmes structures.
Préparer les données.
Décliner plusieurs cas proches.
Copier puis adapter un scénario.
Ce coût baisse fortement lorsque l'outil peut produire le squelette en quelques secondes.
C'est une excellente chose.
Mais notre responsabilité se déplace.
Nous devons davantage décider :
- quelles règles doivent être testées ;
- à quel niveau les tester ;
- quels scénarios sont réellement différents ;
- quelles assertions expriment le comportement important ;
- quels tests sont redondants ;
- quelles hypothèses viennent de l'implémentation et lesquelles viennent du métier.
Autrement dit, l'IA automatise une partie de la construction.
Elle n'automatise pas la responsabilité de décider ce qui mérite d'être cru lorsque la suite devient verte.
Changer la manière de demander des tests à l'IA
Au lieu de commencer par :
Écris tous les tests de cette classe.
je préfère une approche en plusieurs étapes.
D'abord :
Analyse ce comportement.
Identifie :
- les règles métier ;
- les invariants ;
- les entrées importantes ;
- les cas limites ;
- les scénarios pouvant produire des données incorrectes ;
- les régressions les plus coûteuses.
Ne génère pas encore le code des tests.
Puis on examine les scénarios proposés.
Certains sont évidents.
D'autres sont inutiles.
Certains révèlent une règle à laquelle on n'avait pas pensé.
D'autres montrent que le besoin lui-même n'est pas assez clair pour pouvoir écrire une assertion correcte.
Ce dernier cas est particulièrement intéressant.
Si personne ne sait quelle valeur un test doit attendre, le problème n'est peut-être pas l'absence de test.
Le comportement n'est peut-être simplement pas défini.
Une fois les scénarios sélectionnés, l'IA peut reprendre ce qu'elle fait très bien :
Implémente maintenant ces cinq scénarios
en utilisant les conventions de tests existantes du projet.
Le rôle de l'IA change alors.
Elle n'est plus uniquement une machine à produire davantage de tests.
Elle devient un outil pour accélérer une stratégie de tests décidée consciemment.
Relire un test généré comme du code de production
Un test généré doit finalement être traité comme n'importe quel autre code ajouté au repository.
Avant de le conserver, quelques questions simples peuvent suffire :
Quelle règle ce test protège-t-il ?
Est-ce une règle de notre application
ou seulement un comportement du framework ?
Si ce test casse dans six mois,
comprendrai-je pourquoi ?
L'assertion vérifie-t-elle le comportement important ?
Ce scénario est-il déjà couvert ailleurs ?
Le test restera-t-il valide après un refactoring
qui ne change aucun comportement observable ?
Les données de test rendent-elles le scénario compréhensible ?
Si personne ne sait répondre à la première question, il est peut-être trop tôt pour ajouter le test.
La génération ne doit pas nous dispenser de la revue.
Elle la rend au contraire encore plus importante parce que nous pouvons désormais produire beaucoup plus de code que nous n'en écrivions manuellement.
Le but n'est pas d'avoir moins de tests
Il serait facile de résumer cet article par :
"Il vaut mieux 15 tests que 100."
Ce n'est pas ma conclusion.
Une suite large est extrêmement utile lorsqu'elle protège réellement le système.
Le problème apparaît quand le nombre devient un substitut à la réflexion.
Cent tests compris, ciblés et maintenus valent évidemment mieux que quinze tests si les cent apportent une protection supplémentaire pertinente.
La question est plutôt :
Pourquoi le seizième test mérite-t-il d'exister ?
Puis le dix-septième.
Puis le centième.
L'IA rend désormais leur écriture beaucoup moins coûteuse.
Elle ne rend pas automatiquement leur maintenance gratuite.
Le nouveau goulot d'étranglement est notre compréhension
L'IA nous permet d'écrire plus vite.
Elle nous permet aussi de produire davantage de tests, de fixtures, de mocks et de cas limites.
C'est un gain considérable.
Mais lorsque la production de code devient moins chère, la valeur se déplace vers ce qui reste difficile à automatiser :
- comprendre le métier ;
- identifier les invariants ;
- évaluer le risque ;
- choisir le bon niveau de test ;
- décider qu'un scénario ne mérite pas d'être conservé ;
- interpréter correctement un échec plusieurs mois plus tard.
Une suite de tests n'est pas utile parce qu'elle est verte.
Elle est utile parce que nous savons ce que ce vert nous autorise raisonnablement à croire.
Entre cent tests générés que personne ne comprend et quinze tests qui protègent précisément les invariants les plus importants, je commencerais par les quinze.
Puis j'ajouterais le seizième.
Mais seulement en sachant pourquoi il mérite d'exister.