Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 le Transliterator d’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.

Un petit Ă©lĂ©phant tient un Ă©cran Ă  rayons X au-dessus d'une pile de fichiers PHP ; Ă  travers l'Ă©cran, une ligne pointillĂ©e suit une valeur d'un fichier Ă  l'autre et se termine sur une marque rouge, lĂ  oĂč un null atteint un appel de mĂ©thode

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.