Aller au contenu

L'atelier : Composer, Pest et la CI

Séance 1 · ~2 périodes

À la fin de ce chapitre, vous serez capables de :

  1. organiser un projet PHP en un fichier par classe, avec namespace et use ;
  2. installer des dépendances avec Composer et expliquer comment l'autoload PSR-4 retrouve vos classes ;
  3. lancer une suite de tests Pest, la filtrer avec --group et --filter, et lire un test en échec ;
  4. lire le résultat du workflow GitHub Actions fourni dans l'onglet Actions ;
  5. faire passer le kata de niveau 1 du dépôt poo-katas-26 et pousser un job vert.

Chaque kata, chaque atelier et le projet final se font dans un dépôt qui contient un composer.json, des tests et un fichier de workflow. D'où ce chapitre en premier.

1. Un fichier par classe

Au chapitre 1, Hero et Dice tenaient dans un seul fichier. Avec quinze classes, ce fichier devient difficile à parcourir, et deux personnes ne peuvent plus y travailler en même temps sans conflit.

La convention, en PHP, est donc : un fichier par classe, et le fichier porte le nom de la classe.

poo-katas-26/
├── composer.json
├── phpunit.xml
├── src/
│   ├── Dice.php          → class Dice
│   ├── Hero.php          → class Hero
│   ├── Inventory.php     → class Inventory
│   ├── Item.php          → class Item
│   └── …                 → une classe de plus à chaque niveau
└── tests/
    ├── Pest.php
    ├── Niveau1Test.php
    └── Niveau2Test.php

Dans poo-katas-26, les tests ne suivent pas les classes mais les niveaux : un fichier NiveauNTest.php par kata. Un niveau, un chapitre, un fichier.

2. namespace et use : le nom de famille des classes

Votre projet et Laravel peuvent chacun contenir une classe appelée Collection. PHP refuse deux classes du même nom, il faut donc pouvoir les distinguer. Le namespace est un préfixe qui s'en charge : un nom de famille pour vos classes.

src/Dice.php :

<?php

declare(strict_types=1);

namespace Dungeon;

class Dice
{
    // …
}

Le nom complet de la classe est maintenant Dungeon\Dice. Ailleurs, on l'importe en haut du fichier avec use, et on l'utilise ensuite par son nom court.

<?php

declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';   // fourni par Composer

use Dungeon\Dice;
use Dungeon\Hero;

$hero = new Hero('Arthur');
$hero->takeDamage(3);

echo $hero, PHP_EOL;
echo 'Le dé du donjon a ', Dice::d6()->sides, ' faces.', PHP_EOL;
Arthur (7/10 PV)
Le dé du donjon a 6 faces.

Cet import ne charge aucun fichier : il dit seulement à PHP quel nom complet se cache derrière le nom court Dice. Le chargement du fichier est le travail de l'autoload, à la section 4.

Attention

Vous allez rencontrer deux usages de use, sans rapport entre eux :

  • use Dungeon\Dice; en haut du fichier, avant la classe : c'est un import de namespace ;
  • use HasFactory; à l'intérieur d'une classe : c'est un trait, du code copié dans la classe, vu au chapitre 6.

Pour les distinguer, regardez si le mot se trouve avant la classe ou à l'intérieur. C'est la confusion la plus fréquente à l'arrivée sur Laravel.

Note

Histoire de PHP. Les namespaces n'existent que depuis PHP 5.3, en 2009. Avant, on préfixait les noms de classes à la main, ce qui donnait des noms à rallonge comme Zend_Db_Table_Abstract. Composer arrive en 2012 ; avant lui, chaque fichier commençait par une pile de require_once qu'il fallait mettre dans le bon ordre. La norme PSR-4, qui lie le namespace au dossier, est publiée en 2013. Les trois ensemble expliquent pourquoi un projet PHP moderne n'a plus qu'une seule ligne de require.

3. Composer : le gestionnaire de dépendances

Vous n'allez pas écrire votre propre outil de tests : Pest existe et il est gratuit. Composer installe Pest et les bibliothèques dont Pest a besoin, dans des versions compatibles avec le projet. Une dépendance, c'est cela : une bibliothèque écrite par quelqu'un d'autre et utilisée par votre projet.

Tout tient dans composer.json, à la racine du projet.

{
    "name": "opmvpc/poo-katas-26",
    "require": {
        "php": ">=8.3"
    },
    "require-dev": {
        "pestphp/pest": "^3"
    },
    "autoload": {
        "psr-4": {
            "Dungeon\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Tests\\": "tests/"
        }
    },
    "scripts": {
        "test": "pest"
    },
    "config": {
        "allow-plugins": {
            "pestphp/pest-plugin": true
        }
    }
}

Les clés qui comptent :

Clé Ce qu'elle dit
require Ce dont le projet a besoin pour tourner. Ici, PHP 8.3 minimum : vérifiez votre version avec php --version avant de continuer.
require-dev Ce dont vous avez besoin pour développer, ici les tests. Un serveur de production les exclut avec composer install --no-dev.
^3 La version 3 et ses mises à jour compatibles : 3.4 et 3.9 sont acceptées, 4.0 non.
autoload Le lien entre vos namespaces et vos dossiers. Voir la section suivante.
scripts Des raccourcis : composer test lancera pest.

Deux commandes, une différence qui compte :

composer install   # installe ce que dit composer.lock
composer update    # cherche des versions plus récentes et réécrit composer.lock

composer.lock note la version précise de chaque paquet installé. Il est versionné avec le code. C'est lui qui permet d'installer exactement les mêmes versions sur votre ordinateur, sur celui de votre binôme et sur GitHub. Pour les exercices fournis, utilisez toujours composer install.

Le dossier vendor/, lui, ne va jamais dans Git : c'est du code téléchargé, que composer install reconstruit à tout moment. Le .gitignore du dépôt s'en occupe.

4. L'autoload PSR-4 : le namespace est le chemin

Une fois le chargement automatique configuré, vous n'écrivez plus de require pour vos propres classes. Une seule ligne, require __DIR__ . '/vendor/autoload.php';, et PHP va chercher le fichier d'une classe au moment où vous l'utilisez.

La règle est la norme PSR-4, et elle tient en une phrase : à partir du préfixe configuré, le namespace est le chemin du dossier et le nom de la classe est le nom du fichier.

Avec "Dungeon\\": "src/" dans composer.json :

Classe Fichier attendu
Dungeon\Dice src/Dice.php
Dungeon\Hero src/Hero.php
Dungeon\Combat\Battle src/Combat/Battle.php

PSR-4 : du namespace au chemin

Composer remplace Dungeon\ par src/, puis chaque \ restant par /. Il ajoute enfin .php. C'est tout : Composer ne fouille aucun dossier.

Astuce

Une classe « introuvable » alors que le fichier existe vient presque toujours d'une de ces trois causes :

  1. le namespace du fichier ne correspond pas à son dossier ;
  2. le nom de la classe ne correspond pas au nom du fichier. La majuscule compte : Linux la distingue, Windows non, d'où des erreurs qui n'apparaissent que sur GitHub ;
  3. vous avez ajouté une correspondance dans composer.json sans relancer composer dump-autoload.

5. Pest : écrire et lire des tests

Un test est un petit programme qui exécute votre code et vérifie le résultat. Voici deux tests du kata de niveau 1.

<?php

declare(strict_types=1);

use Dungeon\Dice;

test('un dé garde le nombre de faces qu\'on lui donne', function (): void {
    expect((new Dice(6))->sides)->toBe(6);
    expect((new Dice(20))->sides)->toBe(20);
})->group('niveau-1');

test('un lancer de dé reste entre 1 et le nombre de faces', function (): void {
    $dice = new Dice(6);

    for ($i = 0; $i < 100; $i++) {
        expect($dice->roll())->toBeGreaterThanOrEqual(1)->toBeLessThanOrEqual(6);
    }
})->group('niveau-1');

Trois éléments seulement :

  • test('…', function () { … }) : un nom en français, qui décrit le comportement attendu, et le code qui le vérifie.
  • expect($valeur)->toBe($attendu) : une assertion, c'est-à-dire une vérification. Pest en propose une centaine. N'essayez pas de les retenir : consultez la documentation de Pest quand vous en avez besoin. Les tests des exercices vous montreront celles dont vous avez besoin.
  • ->group('niveau-1') : une étiquette, qui permet de ne lancer qu'une partie de la suite.

Le deuxième test vérifie cent tirages. C'est ce qu'on demande à un test sur du hasard : pas un tirage, cent. Notez sa limite : un roll() qui renverrait toujours 1 passerait quand même.

Lancer, et filtrer

composer test                          # toute la suite
composer test -- --group=niveau-1      # seulement les tests étiquetés niveau-1
composer test -- --filter=readonly     # seulement les tests dont le nom contient « readonly »

--group lance les tests d'un niveau, --filter sélectionne les tests dont le nom correspond au filtre. Les deux servent tout le temps : le premier pour le kata du jour, le second pour le test que vous êtes en train de réparer. composer test ne fait que lancer vendor/bin/pest ; vous pouvez appeler ce programme directement, avec les mêmes options.

Lire un test en échec

Un test rouge est une adresse. Voici ce que Pest affiche quand takeDamage() oublie de plafonner à zéro.

   FAIL  Tests\Niveau1Test
  ⨯ les points de vie ne descendent jamais sous zéro                           0.01s
  ───────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Niveau1Test > les points de vie ne descendent jamais sous zéro
  Failed asserting that -989 is identical to 0.

  at tests\Niveau1Test.php:59
     55▕     $hero = new Hero('Arthur');
     56▕
     57▕     $hero->takeDamage(999);
     58▕
  ➜  59▕     expect($hero->hp())->toBe(0);
     60▕     expect($hero->isAlive())->toBeFalse();
     61▕ })->group('niveau-1');

  1   tests\Niveau1Test.php:59

  Tests:    1 failed, 9 passed (417 assertions)

Lisez-le dans cet ordre :

  1. le nom du test : les points de vie ne descendent jamais sous zéro. Voilà la règle qui est violée.
  2. le message : Failed asserting that -989 is identical to 0. Attendu 0, obtenu -989.
  3. la flèche : la ligne exacte de la vérification, avec le code autour.

-989, c'est 10 - 999 : la soustraction n'est pas bornée. Il faut empêcher les points de vie de descendre sous zéro, avec max(0, …). Vous n'avez rien deviné : vous avez lu le message.

Lire un test rouge : la règle violée, le message, la ligne

6. GitHub Actions : les tests relancés sur une machine neuve

Vos tests tournent chez vous. À chaque git push, GitHub peut les relancer sur une machine vierge et afficher une coche verte ou une croix rouge sur votre commit.

Un workflow est un fichier YAML dans .github/workflows/. Celui de poo-katas-26 est fourni, vous n'avez rien à écrire. Une tâche, ou job dans l'interface, est une suite d'étapes exécutées sur une machine.

name: Tests

on: [push, pull_request]

jobs:
  katas:
    name: Niveau ${{ matrix.niveau }}
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false        # si le niveau 1 casse, on veut quand même le verdict des autres
      matrix:
        niveau: [1, 2, 3, 4, 5]
    steps:
      - uses: actions/checkout@v4            # cloner votre dépôt dans la machine

      - uses: shivammathur/setup-php@v2      # installer PHP
        with:
          php-version: "8.4"
          coverage: none

      - run: composer install --no-interaction --prefer-dist --no-progress

      - run: composer test -- --group=niveau-${{ matrix.niveau }}

Un run CI : cinq machines vierges et un retour rapide

Trois repères :

  • on: [push, pull_request] : ce qui déclenche l'exécution.
  • runs-on: ubuntu-latest : une machine Ubuntu vierge, créée pour l'occasion et détruite à la fin. Rien de votre ordinateur n'y est. Ce que cela vérifie : le projet s'installe et se teste à partir du seul contenu du dépôt.
  • matrix: niveau: [1..5] : la tâche est lancée cinq fois en parallèle, une par niveau. Dans l'onglet Actions, vous voyez cinq lignes, une par kata.

Une exécution complète n'est verte que si les cinq tâches passent. Tant que les niveaux 2 à 5 ne sont pas écrits, votre objectif est la coche verte de la tâche « Niveau 1 », pas celle du dépôt entier. Les tests relancés sur GitHub arrivent deux minutes après le push et sont identiques pour tout le monde.

Le badge. Dans l'onglet Actions, votre workflow, menu « … », Create status badge : vous obtenez une ligne de Markdown à coller en haut de votre README.md. Votre dépôt affiche alors en permanence le résultat de la branche main.

7. La procédure, pas à pas

Étape 5 : le premier rouge

Les classes de src/ sont fournies avec leur signature, mais le code des méthodes reste à écrire. Pour l'instant, elles signalent « À implémenter ».

public static function d6(): self
{
    throw new \LogicException('À implémenter');
}

En filtrant sur ce seul test, voici ce que Pest affiche.

   FAIL  Tests\Niveau1Test
  ⨯ Dice::d6() et Dice::d20() fabriquent les dés classiques                     0.02s
  ───────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Niveau1Test > Dice::d6() et Dice::d20()…          LogicException
  À implémenter

  at src\Dice.php:19
     15▕
     16▕     /** Fabrique statique : doit renvoyer un dé à 6 faces. */
     17▕     public static function d6(): self
     18▕     {
  ➜  19▕         throw new \LogicException('À implémenter');
     20▕     }
     21▕
     22▕     /** Fabrique statique : doit renvoyer un dé à 20 faces. */
     23▕     public static function d20(): self

  1   src\Dice.php:19
  2   tests\Niveau1Test.php:24

  Tests:    1 failed (0 assertions)

Le message indique le fichier et la ligne où travailler.

Étape 7 : les premiers verts

public static function d6(): self
{
    return new self(6);
}

public static function d20(): self
{
    return new self(20);
}

public function roll(): int
{
    return random_int(1, $this->sides);
}
   FAIL  Tests\Niveau1Test
  ✓ un dé garde le nombre de faces qu'on lui donne                             0.01s
  ✓ un lancer de dé reste entre 1 et le nombre de faces                        0.01s
  ✓ Dice::d6() et Dice::d20() fabriquent les dés classiques
  ⨯ un héros démarre avec tous ses points de vie                               0.01s
  ⨯ on peut choisir les points de vie et la force à la création
  ⨯ takeDamage retire des points de vie
  ⨯ les points de vie ne descendent jamais sous zéro
  ⨯ heal ne dépasse jamais le maximum de points de vie
  ⨯ un héros affiché donne "Arthur (7/10 PV)"
  ⨯ le nom du héros est readonly : le réécrire lève une Error

  Tests:    7 failed, 3 passed (405 assertions)

Trois tests verts sur dix : Dice est terminé, Hero reste à écrire. Un vert partiel est déjà une information utile.

Étape 9 : lire le résultat sur GitHub

Après le push, ouvrez l'onglet Actions de votre fork. Une exécution apparaît, d'abord en cours, puis son résultat s'affiche. Cliquez dessus : cinq tâches, une par niveau.

Si la tâche « Niveau 1 » est rouge alors que les mêmes tests passent chez vous, la différence vient de l'environnement. Le plus souvent : un fichier oublié au moment du commit, ou une majuscule dans un nom de fichier, comme src/dice.php au lieu de src/Dice.php. Windows ne fait pas la différence, Ubuntu si.

Astuce

Dépanner Composer sous Windows. Commencez toujours par php --version, composer --version et php --ini, puis partez du message exact.

  • composer : le terme « composer » n'est pas reconnu : Composer n'est pas dans le PATH. Fermez et rouvrez complètement votre terminal après l'installation ; si le message persiste, réinstallez depuis https://getcomposer.org/Composer-Setup.exe.
  • your php version (8.1.x) does not satisfy that requirement : votre PHP est trop ancien, le dépôt demande 8.3 minimum. Sous Windows, le plus simple est Herd, gratuit, qui installe PHP et Composer proprement.
  • The openssl extension is required ou zip extension is missing : ouvrez le php.ini indiqué par php --ini et retirez le ; devant extension=openssl, extension=zip et extension=mbstring.
  • Utilisez PowerShell ou le terminal intégré de VS Code, pas cmd.exe. Ne travaillez pas dans un dossier synchronisé par OneDrive : la synchronisation bloque des fichiers et composer install échoue avec des messages incompréhensibles.
  • Après une modification de composer.json, rechargez l'autoload comme indiqué à la section 4. En dernier recours, supprimez vendor/ et relancez composer install.
  • Rien de tout cela ? Copiez le message d'erreur complet et montrez-le, avec la commande qui l'a produit.

8. Kata niveau 1 : Dice et Hero

Le dépôt : https://github.com/opmvpc/poo-katas-26. Vous venez de le forker, de le cloner et d'y écrire Dice. Il reste Hero.

composer test -- --group=niveau-1

Les classes sont dans src/. Le code des méthodes reste à écrire ; pour l'instant, elles signalent « À implémenter ». Votre travail est de les remplir jusqu'à ce que le groupe niveau-1 passe au vert. Les tests sont écrits, ne les modifiez pas : lisez-les, ils sont l'énoncé.

Ce qu'ils attendent, exactement :

Dungeon\Dice

  • new Dice(int $sides) ;
  • roll(): int renvoie un entier dans [1, sides], bornes comprises (le test tire un grand nombre de fois) ;
  • Dice::d6() et Dice::d20() : fabriques statiques qui renvoient un dé à 6 et à 20 faces.

Dungeon\Hero

  • new Hero(string $name, int $maxHp = 10, int $strength = 2) ;
  • name et strength sont des propriétés publiques : le test lit $hero->name et $hero->strength ;
  • hp et maxHp sont privées, et se lisent par les méthodes hp(): int et maxHp(): int (le test écrit $hero->hp(), avec les parenthèses) ;
  • hp démarre à maxHp ;
  • takeDamage(3) retire 3 points de vie, sans jamais descendre sous 0 ;
  • heal() remonte les points de vie, sans jamais dépasser maxHp ;
  • isAlive(): bool : vrai tant que hp > 0 ;
  • __toString() renvoie exactement "Arthur (7/10 PV)", respectez l'espace, les barres obliques et le PV ;
  • name est readonly : le test vérifie qu'une tentative d'écriture lève une Error.

Astuce

Le dépôt contient déjà les fichiers de tous les niveaux : Monster.php, Rarity.php et les autres ne vous concernent pas aujourd'hui. Le groupe niveau-1 ne touche que src/Dice.php et src/Hero.php. Ignorez HasHealth.php, il sert au niveau 4.

Quand tout est vert, vous verrez ceci :

   PASS  Tests\Niveau1Test
  ✓ un dé garde le nombre de faces qu'on lui donne                             0.01s
  ✓ un lancer de dé reste entre 1 et le nombre de faces                        0.01s
  ✓ Dice::d6() et Dice::d20() fabriquent les dés classiques
  ✓ un héros démarre avec tous ses points de vie                               0.01s
  ✓ on peut choisir les points de vie et la force à la création
  ✓ takeDamage retire des points de vie
  ✓ les points de vie ne descendent jamais sous zéro
  ✓ heal ne dépasse jamais le maximum de points de vie
  ✓ un héros affiché donne "Arthur (7/10 PV)"
  ✓ le nom du héros est readonly : le réécrire lève une Error

  Tests:    10 passed (418 assertions)

Poussez, puis vérifiez que la tâche « Niveau 1 » est verte dans l'onglet Actions. Comptez 30 minutes. Si vous bloquez plus de dix minutes sur un test, montrez le test et votre code.

À retenir

  • Un fichier par classe, un namespace qui suit le dossier : c'est PSR-4, et c'est ce qui permet à Composer de charger vos classes tout seul.
  • use en haut du fichier importe un namespace ; use dans une classe colle un trait. Même mot, rien à voir.
  • composer install suit composer.lock, qui est versionné avec le code ; vendor/ ne va jamais dans Git.
  • Un test en échec se lit dans l'ordre : le nom du test, le message attendu et obtenu, la ligne pointée par .
  • Sur GitHub, les mêmes tests sont relancés sur une machine neuve à chaque push. Aujourd'hui, seule la tâche « Niveau 1 » doit être verte.

Exercices

  1. Ajoutez le badge de statut dans le README.md de votre fork.
  2. Cassez volontairement votre Dice::d6() en renvoyant un dé à 7 faces, poussez, puis lisez l'exécution rouge dans l'onglet Actions. Repérez la première ligne d'erreur utile. Remettez en état, poussez, revérifiez.
  3. Renommez src/Dice.php en src/dice.php en local, relancez composer test. Que se passe-t-il chez vous, et que se passerait-il sur GitHub ? Expliquez en deux phrases, puis remettez la majuscule.