Personne ne lance un projet en se disant « on ne mettra pas de tests ». Mais les contraintes s'accumulent : des deadlines serrées, un MVP a livrer, une equipe reduite, un framework mal maitrise. Et un jour, on se retrouve avec une application en production, des centaines de fichiers, zero test automatise — et la peur au ventre a chaque mise en production.
C'est une situation courante. Et contrairement a ce qu'on pourrait croire, ce n'est pas une impasse. Il existe des strategies eprouvees pour introduire des tests automatises sur un projet existant, sans tout reecrire et sans bloquer les developpements en cours.
Pourquoi tester un legacy est plus urgent qu'un projet neuf
Sur un projet neuf, le code est frais, l'architecture est connue, les decisions techniques sont documentees. L'equipe sait ce que fait chaque module parce qu'elle vient de l'ecrire. Les tests sont utiles, mais le risque de regression est faible.
Sur un projet legacy, c'est l'inverse. Le code a ete ecrit par des developpeurs qui ne sont plus la. Certains modules n'ont pas ete touches depuis des annees. D'autres ont ete modifies des dizaines de fois sans que personne ne comprenne l'ensemble des effets de bord.
Dans ce contexte, chaque changement est un risque. Modifier une requete SQL, refactorer un service, mettre a jour une dependance — tout peut casser quelque chose d'inattendu. Et sans tests, ces regressions sont invisibles jusqu'a ce qu'un utilisateur les remonte en production.
Le paradoxe est cruel : c'est precisement la ou il n'y a pas de tests qu'on en a le plus besoin. Et c'est precisement la ou le code est le moins testable qu'il faut trouver un moyen de le tester.
La peur du refactoring
Sans filet de securite, les equipes developpent un reflexe naturel : ne toucher a rien. On contourne les problemes plutot que de les resoudre. On duplique du code plutot que d'extraire une abstraction. On ajoute des conditions plutot que de simplifier la logique.
Ce comportement est rationnel — mais il accelere la degradation du code. C'est un cercle vicieux : moins on teste, moins on refactore ; moins on refactore, plus le code devient difficile a tester. Briser ce cercle est la premiere etape pour reprendre le controle d'un projet legacy.
Les tests de caracterisation : documenter avant de changer
Michael Feathers, dans Working Effectively with Legacy Code, definit le code legacy comme « du code sans tests ». Pas du vieux code. Pas du code mal ecrit. Du code dont on ne peut pas verifier le comportement de maniere automatisee.
Sa premiere recommandation est contre-intuitive : avant de corriger quoi que ce soit, ecrire des tests qui capturent le comportement actuel. Meme si ce comportement contient des bugs. Meme si la logique semble incorrecte.
Le principe
Un test de caracterisation ne verifie pas que le code fait ce qu'il devrait. Il verifie que le code fait ce qu'il fait. C'est une photo instantanee du comportement reel.
La methode est simple :
- Appeler la methode ou le endpoint avec des donnees reelles.
- Observer la sortie.
- Ecrire une assertion qui valide cette sortie exacte.
Si le test passe, vous avez documente un comportement. Si vous modifiez le code et que le test echoue, vous savez exactement ce qui a change — et vous pouvez decider si ce changement est intentionnel ou non.
Le golden master testing
Une variante puissante des tests de caracterisation est le golden master testing (aussi appele approval testing). Le principe : capturer la sortie complete d'une operation — une reponse HTTP, un rendu HTML, un export CSV — et la stocker comme reference.
A chaque execution, le test compare la sortie actuelle au fichier de reference. Toute difference est signalee. Si le changement est voulu, on met a jour le golden master. Sinon, on a detecte une regression.
C'est une technique particulierement efficace sur les projets legacy ou la logique est trop complexe pour etre testee unitairement : on teste la sortie globale sans avoir besoin de comprendre chaque ligne de code.
Rendre le code testable sans le reecrire
Le principal obstacle aux tests sur un projet legacy n'est pas le manque de temps. C'est que le code n'est pas structurellement testable. Des dependances codees en dur, des methodes statiques partout, des classes de 2 000 lignes, des appels directs a la base de donnees dans les controleurs — le code resiste aux tests.
La bonne nouvelle : il n'est pas necessaire de tout refactorer pour rendre le code testable. Il suffit de creer des coutures.
Le concept de coutures (seams)
Michael Feathers definit une couture comme un point du code ou l'on peut changer le comportement sans modifier le code lui-meme. C'est un endroit ou l'on peut « brancher » un test en substituant une dependance, en interceptant un appel ou en injectant un double de test.
En PHP, les coutures les plus courantes sont :
- L'injection de dependance — passer un service en parametre au lieu de l'instancier dans la methode.
- Les interfaces — typer sur un contrat plutot que sur une implementation.
- L'heritage — redefinir une methode dans une sous-classe de test.
Extract and override
C'est la technique la plus rapide pour rendre un morceau de code testable sans le restructurer en profondeur. Le principe :
- Identifier la dependance qui bloque le test (un appel HTTP, une requete SQL, un acces fichier).
-
Extraire cet appel dans une methode
protecteddediee. - Dans le test, creer une sous-classe qui surcharge cette methode pour retourner une valeur controlee.
Ce n'est pas elegant. Ce n'est pas l'architecture cible. Mais ca permet d'ecrire un premier test en vingt minutes sur du code qui semblait intestable. Et un test imparfait vaut infiniment mieux que pas de test du tout.
Wrap and delegate
Quand une classe fait trop de choses et qu'on ne peut pas la modifier (dependance externe, classe generee, code critique en production), on peut la wrapper : creer une nouvelle classe qui delegue au code existant, mais expose une interface testable.
Le wrapper ne change rien au comportement. Il ajoute simplement un point d'injection qui permet de substituer la dependance dans les tests. C'est une technique classique pour isoler les appels a des APIs tierces ou a des composants systeme.
L'injection de dependance minimale
Sur un projet legacy, passer a un container d'injection de dependance complet peut etre un chantier enorme. Mais on n'en a pas besoin pour commencer a tester.
Il suffit souvent d'appliquer une regle simple : toute dependance externe doit etre passee en parametre du constructeur. Pas besoin d'interface. Pas besoin de container. Juste un parametre qu'on peut remplacer par un mock dans le test.
Cette approche incrementale transforme progressivement le code vers une architecture plus testable, sans jamais imposer un arret complet des developpements.
La strategie par couche
Une erreur frequente est de vouloir commencer par des tests unitaires. C'est l'approche naturelle — tester les petites briques en isolation — mais sur un projet legacy, c'est souvent la plus difficile. Le code n'est pas decoupe en unites testables. Les dependances sont entrelacees. L'effort pour isoler une classe peut etre disproportionne par rapport a la valeur du test.
Une strategie plus efficace consiste a commencer par le haut et descendre progressivement.
Tests E2E d'abord : le filet de securite large
Les tests end-to-end (E2E) verifient le comportement de l'application du point de vue de l'utilisateur. Ils traversent toute la pile : controleur, service, base de donnees, rendu.
Leur avantage sur un legacy : ils ne necessitent aucune modification du code.
On teste l'application telle qu'elle est, via son interface HTTP ou son interface graphique.
Avec un outil comme Playwright ou Codeception,
on peut couvrir les parcours critiques en quelques jours.
Les tests E2E sont lents et fragiles, c'est vrai. Mais sur un projet sans aucun test, ils offrent un ratio effort/securite imbattable. Dix tests E2E bien cibles protegent mieux que cent tests unitaires sur du code non critique.
Tests d'integration sur les modules critiques
Une fois le filet de securite E2E en place, on peut descendre d'un cran et cibler les modules qui concentrent la logique metier. Les tests d'integration verifient qu'un module fonctionne correctement avec ses dependances reelles (base de donnees, systeme de fichiers, APIs).
Sur un projet Symfony, cela signifie typiquement :
-
Tester les endpoints API avec le
WebTestCasede Symfony. - Verifier les requetes complexes avec une base de test dediee.
- Valider les workflows metier de bout en bout dans le container de services.
Tests unitaires sur la logique metier
Les tests unitaires viennent en dernier — mais ils sont les plus precieux a long terme. Rapides, fiables, precis, ils documentent le comportement attendu de chaque regle metier.
Pour qu'ils soient possibles, il faut que le code ait ete rendu testable grace aux techniques decrites plus haut (coutures, extract and override, injection). C'est pourquoi cette couche arrive en dernier : elle beneficie du travail de refactoring progressif realise lors de l'ajout des tests d'integration.
Prioriser : la matrice risque x frequence de changement
On ne peut pas tout tester d'un coup. Sur un projet legacy de taille significative, atteindre une couverture de 80 % peut prendre des mois. Il faut donc choisir ou investir en premier.
La matrice risque x frequence de changement est un outil simple pour guider cette decision :
- Risque eleve + changements frequents — priorite maximale. C'est le code critique qui bouge souvent : paiement, authentification, calculs metier. Chaque modification sans test est un incident potentiel.
- Risque eleve + changements rares — a couvrir par des tests E2E. Le code est critique mais stable. Un filet de securite large suffit.
- Risque faible + changements frequents — tests d'integration. Le code n'est pas critique mais il evolue souvent. Des tests d'integration evitent les regressions repetees.
- Risque faible + changements rares — derniere priorite. C'est du code stable et non critique. Les tests viendront plus tard, ou jamais — et ce n'est pas un probleme.
Cette matrice permet de concentrer les efforts la ou ils ont le plus d'impact. Elle evite le piege classique de tester du code trivial pendant que les modules critiques restent sans protection.
Outillage PHP concret
L'ecosysteme PHP dispose aujourd'hui d'outils matures pour chaque niveau de la pyramide de tests. Voici les incontournables pour un projet legacy.
PHPUnit
Le standard de facto. PHPUnit couvre les tests unitaires
et les tests d'integration. Sa configuration est simple,
sa documentation exhaustive, et son integration avec les CI/CD
(GitHub Actions, GitLab CI) est native.
Sur un projet Symfony, le composant symfony/test-pack
fournit tout le necessaire : WebTestCase pour les tests HTTP,
KernelTestCase pour les tests de services,
et les outils de debug associes.
Codeception
Pour les tests E2E et les tests d'acceptance,
Codeception offre une syntaxe expressive
et des modules preconfigures pour les applications web.
Il se branche directement sur Symfony
et permet de tester les parcours utilisateur complets.
DAMA DoctrineTestBundle
L'isolation des tests est un probleme recurrent sur les projets
qui utilisent une base de donnees.
DAMA DoctrineTestBundle resout ce probleme elegamment :
chaque test s'execute dans une transaction qui est annulee a la fin.
La base revient a son etat initial apres chaque test,
sans jamais ecrire sur le disque.
Le gain est double : isolation parfaite et performance accrue (pas de purge ni de rechargement de fixtures entre chaque test).
PHPStan comme filet complementaire
PHPStan n'est pas un outil de test au sens strict,
mais c'est un complement indispensable.
L'analyse statique detecte une categorie de bugs
que les tests ne couvrent pas toujours :
types incorrects, variables indefinies, appels de methodes inexistantes,
violations de contrat.
Sur un projet legacy, activer PHPStan au niveau 1 et monter progressivement est une victoire rapide. Chaque niveau supplementaire elimine une classe de bugs potentiels sans ecrire une seule ligne de test.
Plan d'adoption en 4 sprints
Introduire les tests automatises sur un projet legacy ne se fait pas en un jour. Mais il ne faut pas non plus en faire un projet a part. L'objectif est d'integrer la pratique dans le flux de travail existant, sprint apres sprint.
Sprint 1 : la fondation
L'objectif du premier sprint est de poser l'infrastructure.
-
Configurer
PHPUnitavec le bootstrap Symfony. - Mettre en place la CI pour executer les tests a chaque push.
- Ecrire les 5 a 10 premiers tests E2E sur les parcours les plus critiques de l'application.
-
Activer
PHPStanau niveau 1 et corriger les erreurs detectees.
A la fin de ce sprint, l'equipe a un filet de securite minimal et une CI qui valide chaque changement. C'est le socle sur lequel tout le reste s'appuie.
Sprint 2 : couvrir le module critique
En utilisant la matrice risque x frequence, identifier le module le plus critique de l'application. Typiquement : le paiement, la gestion des droits, le calcul de tarification, le workflow de commande.
- Ecrire des tests de caracterisation sur ce module.
- Rendre le code testable avec les techniques de coutures (extract and override, injection minimale).
- Ajouter des tests d'integration couvrant les cas nominaux et les cas d'erreur.
Sprint 3 : tests unitaires et logique metier
Maintenant que le module critique est couvert par des tests d'integration, il est possible d'extraire la logique metier pure et de la tester unitairement.
- Identifier les regles metier enfouies dans les controleurs ou les services.
- Les extraire dans des classes dediees, testables en isolation.
- Ecrire des tests unitaires parametrises couvrant l'ensemble des cas.
-
Monter le niveau de
PHPStand'un cran.
Sprint 4 : mutation testing et consolidation
Le mutation testing est une technique avancee
qui verifie la qualite des tests eux-memes.
Un outil comme Infection modifie le code source
(remplace un > par un >=, supprime une condition,
inverse un booleen) et verifie que les tests detectent le changement.
-
Installer
Infectionet l'executer sur le module critique. - Identifier les mutations survivantes (= trous dans la couverture).
- Completer les tests pour eliminer les survivants.
- Definir une politique : tout nouveau code doit etre livre avec ses tests.
A la fin de ces quatre sprints, l'equipe a transforme sa posture. Le projet n'est plus un legacy sans tests — c'est un projet qui a des tests et une strategie pour en ajouter. La difference est fondamentale.
Les erreurs a eviter
L'introduction de tests sur un legacy peut echouer si l'approche est mal calibree. Voici les pieges les plus courants.
Viser une couverture de 100 %
C'est un objectif contre-productif. La couverture de code est un indicateur, pas un objectif. Un projet avec 60 % de couverture sur le code critique est bien mieux protege qu'un projet avec 90 % de couverture concentree sur des getters et des setters.
Ecrire des tests fragiles
Un test qui casse a chaque modification de l'interface graphique ou a chaque changement de libelle n'est pas un filet de securite — c'est un frein. Les tests doivent verifier le comportement, pas les details d'implementation.
Reporter les tests a « quand on aura le temps »
Ce moment n'arrivera jamais. Les tests doivent etre integres dans le flux de developpement courant, pas traites comme un chantier a part. La regle la plus efficace : tout nouveau code et toute correction de bug s'accompagnent d'un test.
Reprendre le controle
Mettre en place des tests automatises sur un projet legacy n'est pas un luxe reserve aux grandes equipes. C'est un investissement qui se rentabilise en quelques semaines : moins de regressions, plus de confiance dans les mises en production, capacite retrouvee a refactorer et a faire evoluer le produit.
La cle est de ne pas chercher la perfection. Commencer petit, cibler les zones critiques, automatiser progressivement. Chaque test ajoute reduit le risque. Chaque refactoring securise par un test ameliore la qualite du code pour la suite.
Chez x10, nous accompagnons regulierement des equipes dans cette demarche. Notre audit technique permet d'identifier les zones critiques et de definir une strategie de test adaptee. Et notre service de maintenance applicative inclut la mise en place progressive de tests automatises pour securiser les evolutions futures. Si votre projet legacy vous inquiete, parlons-en.