Tests, analyse statique et outillage
Un projet PHP maintenu lance quatre outils Ă chaque commit : un lanceur de tests, un analyseur statique, un correcteur de style de code, et lâaudit intĂ©grĂ© Ă Composer. Aucun de ces outils nâest livrĂ© avec le langage, mais tous sâinstallent avec un seul composer require --dev et se lancent depuis vendor/bin/. Si vous avez dĂ©jĂ utilisĂ© pytest avec mypy, ou Jest avec tsc et Prettier, vous connaissez la forme de cette chaĂźne et il ne vous reste que les noms Ă apprendre.
Ce chapitre nomme deux outils pour chaque tùche, non par indécision, mais parce que les deux sont largement utilisés et bons, et que le projet que vous venez de rejoindre a de toute façon déjà choisi le sien.
Les tests
Deux lanceurs de tests dominent lâĂ©cosystĂšme. PHPUnit est le lanceur de style xUnit sur lequel tous les autres outils de test PHP sâappuient, et Pest est une couche describe-et-it posĂ©e sur le moteur de PHPUnit. Ils partagent les assertions, les mocks, la configuration et la machinerie de couverture, et ne diffĂšrent que par la façon dont un test se lit.
La fonction Ă tester :
<?php
declare(strict_types=1);
namespace App;
function slugify(string $title): string
{
$slug = strtolower(trim($title));
$slug = preg_replace('/[^a-z0-9]+/', '-', $slug);
return trim($slug, '-');
}
Une fonction dans un espace de noms nâest pas une classe, donc PSR-4 ne peut pas la trouver : le fichier va dans une entrĂ©e files du bloc autoload de composer.json, et Composer le charge avec require Ă chaque exĂ©cution.
Le mĂȘme test, PHPUnit dâabord :
<?php
declare(strict_types=1);
namespace App\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use function App\slugify;
final class SlugifyTest extends TestCase
{
#[Test]
public function itLowercasesAndJoinsWithDashes(): void
{
self::assertSame('hello-world', slugify('Hello World'));
}
#[Test]
#[DataProvider('edgeCases')]
public function itHandlesEdgeCases(string $input, string $expected): void
{
self::assertSame($expected, slugify($input));
}
public static function edgeCases(): iterable
{
yield 'leading punctuation' => ['!!Hi', 'hi'];
yield 'empty' => ['', ''];
yield 'unicode is stripped' => ['café', 'caf'];
}
}
Puis Pest :
<?php
declare(strict_types=1);
use function App\slugify;
it('lowercases and joins with dashes', function () {
expect(slugify('Hello World'))->toBe('hello-world');
});
it('handles edge cases', function (string $input, string $expected) {
expect(slugify($input))->toBe($expected);
})->with([
'leading punctuation' => ['!!Hi', 'hi'],
'empty' => ['', ''],
'unicode is stripped' => ['café', 'caf'],
]);
Lancez-les avec vendor/bin/phpunit ou vendor/bin/pest. La configuration vit dans phpunit.xml Ă la racine du projet, et Pest lit le mĂȘme fichier : on y dĂ©clare quels dossiers contiennent les tests, sâil faut Ă©chouer sur un warning, et quelles variables dâenvironnement dĂ©finir.
Les conventions PHPUnit Ă connaĂźtre dĂšs le premier jour : une classe de test se termine par Test et Ă©tend TestCase, une mĂ©thode de test porte lâattribut #[Test] ou commence par test, setUp() sâexĂ©cute avant chaque test, self::assertSame() est lâassertion stricte (il existe un assertEquals(), qui jongle avec les types comme ==, donc prĂ©fĂ©rez assertSame()), et $this->createMock(SomeInterface::class) renvoie un double de test que vous configurez avec ->method('name')->willReturn($value).
La couverture nâest pas intĂ©grĂ©e au langage et demande une extension qui observe quelles lignes sâexĂ©cutent, soit Xdebug (avec xdebug.mode=coverage), soit PCOV, qui ne fait que de la couverture et le fait plus vite. Dans les deux cas, vendor/bin/phpunit --coverage-text affiche le rapport.
Le test unicode ci-dessus documente un bug :
slugify('cafĂ©')supprime leĂ©au lieu de le translittĂ©rer. Un test qui fige le comportement actuel reste un test utile, et vous corrigerez la fonction plus tard avec leTransliteratordâintl.
Lâanalyse statique
PHP vĂ©rifie les types Ă lâexĂ©cution, un appel Ă la fois, et il ne vous dira donc jamais quâune fonction situĂ©e trois fichiers plus loin peut renvoyer null sans que vous le gĂ©riez. PHPStan et Psalm jouent le rĂŽle du compilateur que PHP nâa pas : ils lisent toute la base de code, suivent chaque type Ă travers chaque appel, et signalent ce qui Ă©chouerait avant que quoi que ce soit ne sâexĂ©cute, comme mypy le fait pour Python ou le vĂ©rificateur de types de tsc pour TypeScript.
<?php
declare(strict_types=1);
function findUser(int $id): ?User
{
return $id === 1 ? new User('Ada') : null;
}
echo findUser(2)->name;
PHP exĂ©cute ce code et plante sur la seconde ligne avec « Attempt to read property on null », alors que les deux analyseurs le refusent avant mĂȘme de lâexĂ©cuter :
Cannot access property $name on User|null.
Les deux outils fonctionnent par niveaux : PHPStan va de 0 (erreurs Ă©videntes seulement) Ă 10 (chaque mixed doit ĂȘtre prĂ©cisĂ©), et Psalm compte dans lâautre sens, de 8 (permissif) Ă 1 (strict). Un nouveau projet dĂ©marre au niveau le plus strict quâil peut tenir et nâen redescend jamais, tandis quâun projet ancien gĂ©nĂšre une baseline, un fichier qui liste toutes les erreurs actuelles pour que seules les nouvelles fassent Ă©chouer le build, puis la rĂ©duit au fil du temps.
Un phpstan.neon minimal :
parameters:
level: 8
paths:
- src
- tests
Les deux outils lisent les docblocks pour ce que le langage ne sait pas exprimer : @param list<int> $ids, @return array<string, User>, et les gĂ©nĂ©riques @template prĂ©sentĂ©s dans Le systĂšme de types. Câest dans ces docblocks que vivent les gĂ©nĂ©riques en PHP : le moteur les ignore et lâanalyseur les fait respecter.
Le style de code
PER Coding Style, publiĂ© par le PHP-FIG, est le guide de style de rĂ©fĂ©rence. Il a succĂ©dĂ© Ă PSR-12, qui avait succĂ©dĂ© Ă PSR-2, et tous les frameworks et la plupart des bibliothĂšques le suivent : quatre espaces, accolades sur leur propre ligne pour les classes et les fonctions, sur la mĂȘme ligne pour les structures de contrĂŽle, une classe par fichier. Vous nâavez pas Ă lâapprendre par cĆur, puisquâun outil lâapplique pour vous.
PHP-CS-Fixer réécrit les fichiers pour respecter un jeu de rĂšgles. PHP_CodeSniffer signale les violations avec phpcs et corrige ce quâil peut avec phpcbf. Les deux acceptent PER en une ligne de configuration. Pour PHP-CS-Fixer, .php-cs-fixer.dist.php :
<?php
declare(strict_types=1);
$finder = PhpCsFixer\Finder::create()->in([__DIR__ . '/src', __DIR__ . '/tests']);
return (new PhpCsFixer\Config())
->setRules(['@PER-CS' => true])
->setFinder($finder);
Pour PHP_CodeSniffer, phpcs.xml :
<?xml version="1.0"?>
<ruleset name="project">
<rule ref="PSR12"/>
<file>src</file>
<file>tests</file>
</ruleset>
Choisissez-en un et lancez-le en CI, ce qui met fin aux discussions sur le placement des accolades en revue de code.
Les migrations
Rector réécrit votre code vers une version plus rĂ©cente de PHP, ou dâun framework, automatiquement. Il sait que Foo $x = null doit devenir ?Foo $x = null, quâun switch qui renvoie une valeur est un match, quâun constructeur qui affecte des propriĂ©tĂ©s peut les promouvoir. Donnez-lui une version cible et il fait la partie mĂ©canique dâune migration :
<?php
declare(strict_types=1);
use Rector\Config\RectorConfig;
use Rector\ValueObject\PhpVersion;
return RectorConfig::configure()
->withPaths([__DIR__ . '/src', __DIR__ . '/tests'])
->withPhpVersion(PhpVersion::PHP_84)
->withPreparedSets(deadCode: true, codeQuality: true);
vendor/bin/rector --dry-run montre le diff. Sans lâoption, il lâapplique. Le chapitre Revenir Ă PHP aprĂšs des annĂ©es est, en pratique, la liste de ce que Rector fera au code que vous avez laissĂ© derriĂšre vous.
Le débogage
var_dump($value) affiche une valeur avec son type sans interrompre lâexĂ©cution, et var_dump($value); exit; reste le dĂ©bogueur le plus rapide qui soit. Les frameworks ajoutent un dump() et un dd() (dump and die) plus agrĂ©ables Ă lire, mais celui du langage a lâavantage de marcher partout.
Xdebug est le seul dĂ©bogueur pas Ă pas de lâĂ©cosystĂšme. Une fois lâextension installĂ©e et xdebug.mode=debug placĂ© dans php.ini, votre Ă©diteur sâarrĂȘte sur les points dâarrĂȘt, montre la pile et vous laisse inspecter les variables, dans les requĂȘtes web comme dans les scripts CLI. Câest une extension rĂ©servĂ©e au dĂ©veloppement, parce quâelle ralentit tout : les builds de production sâen passent, et quand il vous faut de la vitesse en local, php -d xdebug.mode=off script.php la dĂ©sactive le temps dâune exĂ©cution.
CĂŽtĂ© Ă©diteurs, PhpStorm intĂšgre le langage et VS Code a besoin dâune extension PHP. Les deux lisent les mĂȘmes docblocks que les analyseurs, donc les gĂ©nĂ©riques que vous Ă©crivez pour PHPStan ou Psalm alimentent aussi lâautocomplĂ©tion.
Relier le tout
Les scripts Composer donnent au projet un vocabulaire unique, quels que soient les outils choisis. Dans composer.json :
{
"scripts": {
"test": "phpunit",
"lint": "php-cs-fixer fix --dry-run --diff",
"lint:fix": "php-cs-fixer fix",
"analyse": "phpstan analyse",
"check": ["@lint", "@analyse", "@test"]
}
}
composer check lance les trois. Composer place vendor/bin dans le chemin des scripts, donc les noms dâoutils nâont pas besoin de prĂ©fixe. Un nouveau membre de lâĂ©quipe lit composer.json et sait comment le projet est vĂ©rifiĂ© sans ouvrir de README.
La CI lance les mĂȘmes commandes, sur chaque version de PHP que le projet prend en charge (la contrainte php de composer.json dit lesquelles), plus composer audit, qui confronte composer.lock Ă la base des vulnĂ©rabilitĂ©s connues et Ă©choue Ă la premiĂšre trouvĂ©e. composer outdated liste ce qui a une version plus rĂ©cente ; un robot de mise Ă jour des dĂ©pendances peut ouvrir les pull requests pour vous.
Pour un runtime local, php -S localhost:8000 -t public sert le projet comme lâa montrĂ© Une requĂȘte web, sans framework, et lâimage Docker officielle php:8.5-cli donne Ă tout le monde le mĂȘme interprĂ©teur. Nâimporte quel rĂ©glage de php.ini peut ĂȘtre surchargĂ© pour une commande avec php -d memory_limit=1G. Sachez enfin que les fichiers .env sont une convention de bibliothĂšques, que plusieurs paquets chargent dans lâenvironnement, et non quelque chose que PHP lit de lui-mĂȘme.
Le piĂšge
Les deux erreurs classiques tiennent au moment oĂč lâon fait les choses.
La premiĂšre consiste Ă Ă©crire des tests qui touchent la base de donnĂ©es par dĂ©faut : ils passent sur la machine de lâauteur, prennent des minutes en CI, et finissent par ne plus ĂȘtre lancĂ©s. Testez la fonction, passez la dĂ©pendance par le constructeur, et gardez les tests dâintĂ©gration dans leur propre dossier avec leur propre suite dans phpunit.xml, pour quâils sâexĂ©cutent quand on le dĂ©cide.
La seconde consiste Ă lancer lâanalyse statique Ă la fin du projet. Une base de code qui atteint le niveau 8 dĂšs le premier jour y reste sans y penser, alors quâune base de code qui rencontre PHPStan aprĂšs deux ans lâaccueille avec quatre mille erreurs et une baseline que personne ne rĂ©duit. Ajoutez lâanalyseur au premier commit, au niveau le plus Ă©levĂ© qui passe, et montez-le dĂšs que câest possible.
Une fois les outils en place, il reste la question que tout dĂ©veloppeur polyglotte pose la premiĂšre semaine, celle de lâasynchrone, et Concurrence et performance y rĂ©pond.