L'atelier : Composer, Pest et la CI
Séance 1 · ~2 périodes
À la fin de ce chapitre, vous serez capables de :
- organiser un projet PHP en un fichier par classe, avec
namespaceetuse; - installer des dépendances avec Composer et expliquer comment l'autoload PSR-4 retrouve vos classes ;
- lancer une suite de tests Pest, la filtrer avec
--groupet--filter, et lire un test en échec ; - lire le résultat du workflow GitHub Actions fourni dans l'onglet Actions ;
- faire passer le kata de niveau 1 du dépôt
poo-katas-26et 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 |
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 :
- le
namespacedu fichier ne correspond pas à son dossier ; - 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 ;
- vous avez ajouté une correspondance dans
composer.jsonsans relancercomposer 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 :
- le nom du test :
les points de vie ne descendent jamais sous zéro. Voilà la règle qui est violée. - le message :
Failed asserting that -989 is identical to 0. Attendu0, obtenu-989. - 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.

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 }}
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 lePATH. 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 requiredouzip extension is missing: ouvrez lephp.iniindiqué parphp --iniet retirez le;devantextension=openssl,extension=zipetextension=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 etcomposer 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, supprimezvendor/et relancezcomposer 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(): intrenvoie un entier dans[1, sides], bornes comprises (le test tire un grand nombre de fois) ;Dice::d6()etDice::d20(): fabriques statiques qui renvoient un dé à 6 et à 20 faces.
Dungeon\Hero
new Hero(string $name, int $maxHp = 10, int $strength = 2);nameetstrengthsont des propriétés publiques : le test lit$hero->nameet$hero->strength;hpetmaxHpsont privées, et se lisent par les méthodeshp(): intetmaxHp(): int(le test écrit$hero->hp(), avec les parenthèses) ;hpdémarre àmaxHp;takeDamage(3)retire 3 points de vie, sans jamais descendre sous 0 ;heal()remonte les points de vie, sans jamais dépassermaxHp;isAlive(): bool: vrai tant quehp > 0;__toString()renvoie exactement"Arthur (7/10 PV)", respectez l'espace, les barres obliques et lePV;nameestreadonly: le test vérifie qu'une tentative d'écriture lève uneError.
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
namespacequi suit le dossier : c'est PSR-4, et c'est ce qui permet à Composer de charger vos classes tout seul. useen haut du fichier importe un namespace ;usedans une classe colle un trait. Même mot, rien à voir.composer installsuitcomposer.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
- Ajoutez le badge de statut dans le
README.mdde votre fork. - 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. - Renommez
src/Dice.phpensrc/dice.phpen local, relancezcomposer test. Que se passe-t-il chez vous, et que se passerait-il sur GitHub ? Expliquez en deux phrases, puis remettez la majuscule.