Chapitre 626 minIntermédiaire
Tests unitaires en PHP avec PHPUnit
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- installer PHPUnit avec Composer et configurer phpunit.xml ;
- écrire des tests avec les attributs #[Test] et #[DataProvider] ;
- choisir entre assertSame et assertEquals ;
- tester une classe d'accès aux données PDO avec SQLite en mémoire.
3 exercices corrigés
PHPUnit est le framework de test de référence en PHP : on écrit une classe qui hérite de TestCase, chaque méthode marquée #[Test] est un test, et les assertions (assertSame, assertNull…) comparent l'obtenu à l'attendu. On l'installe avec Composer et on le lance avec vendor/bin/phpunit. Ce chapitre teste en PHP 8.4 et PHPUnit 13 les règles de réservation de Prêt'Matos, puis l'accès aux données avec PDO, sans framework.
Installer PHPUnit avec Composer
PHPUnit 13, la version de février 2026, demande PHP 8.4 ou plus. Il s'installe comme une dépendance de développement du projet, avec Composer :
composer require --dev phpunit/phpunit ^13
vendor/bin/phpunit --version
Le projet suit une organisation simple : le code dans src/, les tests dans tests/, et Composer charge les classes automatiquement grâce à l'autoload PSR-4 déclaré dans composer.json. Chaque espace de noms correspond à un dossier :
{
"autoload": {
"psr-4": { "PretMatos\\": "src/" }
},
"autoload-dev": {
"psr-4": { "PretMatos\\Tests\\": "tests/" }
},
"require": {
"php": ">=8.4"
},
"require-dev": {
"phpunit/phpunit": "^13"
}
}
Après avoir modifié l'autoload, lance composer dump-autoload. La commande vendor/bin/phpunit --generate-configuration crée ensuite un fichier phpunit.xml en posant quelques questions ; voici une version minimale :
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true">
<testsuites>
<testsuite name="pret-matos">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
bootstrap charge l'autoloader de Composer avant les tests ; <testsuites> dit où chercher les tests ; <source> dit quel code est le tien, ce qui sert à mesurer la couverture. Désormais, vendor/bin/phpunit lance toute la suite.
La classe à tester
La règle de réservation en PHP, sans framework. Elle reçoit la date du jour dans son constructeur : le test pourra lui donner une date fixe.
<?php
// src/VerificateurReservation.php
declare(strict_types=1);
namespace PretMatos;
final class VerificateurReservation
{
public const int DUREE_MAX_JOURS = 15;
public function __construct(private \DateTimeImmutable $aujourdhui)
{
}
/**
* @param array{materiel: string, debut: string, fin: string} $demande
* @param list<array{materiel: string, debut: string, fin: string}> $existantes
* @return string|null le motif du refus, ou null si la réservation est acceptée
*/
public function verifier(array $demande, array $existantes): ?string
{
$debut = new \DateTimeImmutable($demande['debut']);
$fin = new \DateTimeImmutable($demande['fin']);
if ($fin < $debut) {
return 'La date de fin précède la date de début.';
}
if ($debut < $this->aujourdhui) {
return 'La date de début est déjà passée.';
}
if ($debut->diff($fin)->days + 1 > self::DUREE_MAX_JOURS) {
return sprintf('Un prêt dure au plus %d jours.', self::DUREE_MAX_JOURS);
}
foreach ($existantes as $r) {
if ($r['materiel'] === $demande['materiel']
&& $r['debut'] <= $demande['fin'] && $demande['debut'] <= $r['fin']) {
return sprintf(
'%s est déjà réservé du %s au %s.',
$demande['materiel'],
(new \DateTimeImmutable($r['debut']))->format('d/m/Y'),
(new \DateTimeImmutable($r['fin']))->format('d/m/Y'),
);
}
}
return null;
}
}
Quelques détails de PHP moderne : declare(strict_types=1) interdit les conversions de type silencieuses ; la promotion de propriété dans le constructeur (private \DateTimeImmutable $aujourdhui) déclare et initialise la propriété en une ligne ; la constante typée public const int existe depuis PHP 8.3. La comparaison $r['debut'] <= $demande['fin'] compare des chaînes AAAA-MM-JJ, ce qui revient à comparer des dates : ce format se trie dans l'ordre chronologique.
Le premier test
<?php
// tests/VerificateurReservationTest.php
declare(strict_types=1);
namespace PretMatos\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use PretMatos\VerificateurReservation;
final class VerificateurReservationTest extends TestCase
{
private VerificateurReservation $verificateur;
private array $existantes;
// Appelée avant chaque test : chaque test part d'un contexte neuf.
protected function setUp(): void
{
$this->verificateur = new VerificateurReservation(new \DateTimeImmutable('2027-03-08'));
$this->existantes = [['materiel' => 'PC-07', 'debut' => '2027-03-10', 'fin' => '2027-03-14']];
}
#[Test]
public function accepteUnMaterielLibre(): void
{
$demande = ['materiel' => 'PC-07', 'debut' => '2027-03-15', 'fin' => '2027-03-18'];
$motif = $this->verificateur->verifier($demande, $this->existantes);
$this->assertNull($motif);
}
#[Test]
public function refuseUnMaterielDejaReserve(): void
{
$demande = ['materiel' => 'PC-07', 'debut' => '2027-03-12', 'fin' => '2027-03-18'];
$motif = $this->verificateur->verifier($demande, $this->existantes);
$this->assertSame('PC-07 est déjà réservé du 10/03/2027 au 14/03/2027.', $motif);
}
#[Test]
public function refuseUneDateDeDebutPassee(): void
{
$demande = ['materiel' => 'VP-02', 'debut' => '2027-03-07', 'fin' => '2027-03-09'];
$this->assertSame('La date de début est déjà passée.', $this->verificateur->verifier($demande, $this->existantes));
}
}
| Élément | Rôle |
|---|---|
extends TestCase | Donne accès aux assertions et au cycle de vie des tests |
final class …Test | Par convention, le nom de la classe se termine par Test et le fichier porte le même nom |
setUp() | Exécutée avant chaque test, sur une instance neuve : l'équivalent du beforeEach de Vitest. tearDown() s'exécute après chaque test |
#[Test] | Un attribut PHP 8 qui marque la méthode comme un test. Sans lui, le nom de la méthode doit commencer par test (testAccepteUnMaterielLibre) |
$this->assertSame(attendu, obtenu) | L'attendu d'abord, comme avec xUnit |
Les assertions de PHPUnit
| Assertion | Vérifie que… |
|---|---|
assertSame($attendu, $obtenu) | les deux valeurs sont identiques : même type et même valeur (===) |
assertEquals($attendu, $obtenu) | les deux valeurs sont égales après conversion (==) |
assertTrue($x), assertFalse($x) | la valeur est exactement true, ou false |
assertNull($x), assertNotNull($x) | la valeur est null, ou non |
assertCount($n, $tableau) | le tableau a $n éléments |
assertContains($element, $tableau) | le tableau contient l'élément |
assertStringContainsString($aiguille, $chaine) | la chaîne contient la sous-chaîne |
assertArrayHasKey($cle, $tableau) | le tableau a cette clé |
assertInstanceOf(Classe::class, $objet) | l'objet est une instance de la classe |
expectException(Classe::class) | le code qui suit lève une exception de ce type |
Les fournisseurs de données : #[DataProvider]
Pour rejouer un test avec plusieurs jeux de valeurs, on écrit une méthode publique et statique qui renvoie un tableau de jeux, et on la relie au test par l'attribut #[DataProvider]. Les clés du tableau donnent un nom à chaque jeu, affiché en cas d'échec.
public static function durees(): array
{
return [
'15 jours, la durée maximale' => ['2027-03-29', null],
'16 jours, un de trop' => ['2027-03-30', 'Un prêt dure au plus 15 jours.'],
];
}
#[Test]
#[DataProvider('durees')]
public function respecteLaDureeMaximale(string $fin, ?string $motifAttendu): void
{
$demande = ['materiel' => 'VP-02', 'debut' => '2027-03-15', 'fin' => $fin];
$this->assertSame($motifAttendu, $this->verificateur->verifier($demande, $this->existantes));
}
Pour quelques valeurs simples, l'attribut #[TestWith(['2027-03-29', null])] évite d'écrire une méthode séparée : un attribut par jeu de valeurs.
Tester une exception
Avec PHPUnit, on annonce l'exception avant d'exécuter le code qui doit la lever :
#[Test]
public function refuseUneDateMalFormee(): void
{
$this->expectException(\DateMalformedStringException::class);
$this->verificateur->verifier(['materiel' => 'VP-02', 'debut' => 'demain', 'fin' => '32/13/2027'], []);
}
Depuis PHP 8.3, le constructeur de DateTimeImmutable lève une DateMalformedStringException pour une chaîne qu'il ne sait pas lire. Ce test documente donc un comportement réel : une date absurde ne passe pas silencieusement. expectExceptionMessage('…') vérifie en plus le message.
Tester l'accès aux données avec PDO
Une classe d'accès aux données (un repository, ou dépôt) exécute du SQL : la tester sans base n'aurait pas de sens. Pour un test rapide et autonome, on peut lui donner une base SQLite en mémoire, créée à chaque test et détruite à la fin, grâce à PDO :
<?php
// src/ReservationRepository.php
declare(strict_types=1);
namespace PretMatos;
final class ReservationRepository
{
public function __construct(private \PDO $pdo)
{
}
/** @return list<array{materiel: string, debut: string, fin: string}> */
public function reservationsDu(string $materiel): array
{
$requete = $this->pdo->prepare(
'SELECT materiel, debut, fin FROM reservation WHERE materiel = :materiel ORDER BY debut'
);
$requete->execute(['materiel' => $materiel]);
return $requete->fetchAll(\PDO::FETCH_ASSOC);
}
public function ajouter(string $materiel, string $debut, string $fin, int $salarie): int
{
$requete = $this->pdo->prepare(
'INSERT INTO reservation (materiel, debut, fin, salarie) VALUES (:materiel, :debut, :fin, :salarie)'
);
$requete->execute(['materiel' => $materiel, 'debut' => $debut, 'fin' => $fin, 'salarie' => $salarie]);
return (int) $this->pdo->lastInsertId();
}
}
<?php
// tests/ReservationRepositoryTest.php
declare(strict_types=1);
namespace PretMatos\Tests;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use PretMatos\ReservationRepository;
final class ReservationRepositoryTest extends TestCase
{
private \PDO $pdo;
private ReservationRepository $depot;
protected function setUp(): void
{
// Une base SQLite en mémoire : créée pour chaque test, détruite à la fin.
$this->pdo = new \PDO('sqlite::memory:', options: [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
$this->pdo->exec('CREATE TABLE reservation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
materiel VARCHAR(10) NOT NULL,
debut DATE NOT NULL,
fin DATE NOT NULL,
salarie INTEGER NOT NULL
)');
$this->pdo->exec("INSERT INTO reservation (materiel, debut, fin, salarie) VALUES ('PC-07', '2027-03-10', '2027-03-14', 5)");
$this->depot = new ReservationRepository($this->pdo);
}
#[Test]
public function relitLesReservationsDUnMateriel(): void
{
$reservations = $this->depot->reservationsDu('PC-07');
$this->assertSame([['materiel' => 'PC-07', 'debut' => '2027-03-10', 'fin' => '2027-03-14']], $reservations);
}
#[Test]
public function ajouteUneReservationEtRenvoieSonIdentifiant(): void
{
$id = $this->depot->ajouter('PC-07', '2027-03-20', '2027-03-22', 3);
$this->assertSame(2, $id);
$this->assertCount(2, $this->depot->reservationsDu('PC-07'));
}
#[Test]
public function renvoieUneListeVidePourUnMaterielSansReservation(): void
{
$this->assertSame([], $this->depot->reservationsDu('VP-02'));
}
}
Le dépôt reçoit son objet PDO dans le constructeur : en production, un PDO connecté à MySQL ; dans le test, un PDO connecté à SQLite. C'est encore l'injection de dépendances. Les requêtes préparées (prepare puis execute avec des paramètres nommés) sont celles du cours SQL : elles protègent aussi contre l'injection SQL.
Lancer les tests et lire le résultat
$ vendor/bin/phpunit
PHPUnit 13.4.6 by Sebastian Bergmann and contributors.
Runtime: PHP 8.4.27
Configuration: /home/lina/pret-matos/phpunit.xml
......... 9 / 9 (100%)
Time: 00:00.021, Memory: 10.00 MB
OK (9 tests, 10 assertions)
Chaque point est un test réussi. Les autres caractères possibles : F (failure, une assertion a échoué), E (error, une exception inattendue a interrompu le test), S (skipped, test sauté), I (incomplete), R (risky, par exemple un test sans aucune assertion), D (deprecation), N (notice), W (warning). Les deux jeux du fournisseur durees comptent pour deux tests.
En cas d'échec, PHPUnit donne la classe, la méthode, le jeu de données et le fichier :
There was 1 failure:
1) PretMatos\Tests\VerificateurReservationTest::respecteLaDureeMaximale with data set "16 jours, un de trop" ('2027-03-30', 'Un prêt dure au plus 15 jours.')
Failed asserting that null is identical to 'Un prêt dure au plus 15 jours.'.
/home/lina/pret-matos/tests/VerificateurReservationTest.php:66
FAILURES!
Tests: 9, Assertions: 10, Failures: 1.
Ici, la fonction a renvoyé null (acceptée) pour un prêt de 16 jours : le + 1 du calcul de durée a disparu. Le nom du jeu de données, « 16 jours, un de trop », dit immédiatement quelle limite a cédé.
Pour lancer une partie des tests : vendor/bin/phpunit --filter respecteLaDureeMaximale ou vendor/bin/phpunit tests/ReservationRepositoryTest.php. Dans VS Code, l'extension PHPUnit Test Explorer affiche les tests dans le panneau Testing ; PhpStorm les intègre nativement.
Exercices
Exercice 1 sur 3 Écrire les tests d'une fonction PHP Réussi À revoir
Voici une fonction de Prêt'Matos en PHP :
function joursDeRetard(string $retourPrevu, string $aujourdhui): int
{
$prevu = new \DateTimeImmutable($retourPrevu);
$jour = new \DateTimeImmutable($aujourdhui);
return $jour > $prevu ? $prevu->diff($jour)->days : 0;
}
Écris une classe de test PHPUnit avec un fournisseur de données qui couvre : avant le retour prévu, le jour du retour, le lendemain, et un retard qui traverse la fin du mois de mars.
Voir la réponseMasquer la réponse de l'exercice 1
<?php
declare(strict_types=1);
namespace PretMatos\Tests;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class JoursDeRetardTest extends TestCase
{
public static function cas(): array
{
return [
'avant le retour prévu' => ['2027-03-14', '2027-03-10', 0],
'le jour du retour' => ['2027-03-14', '2027-03-14', 0],
'le lendemain' => ['2027-03-14', '2027-03-15', 1],
'après la fin du mois' => ['2027-03-30', '2027-04-02', 3],
];
}
#[Test]
#[DataProvider('cas')]
public function calculeLeRetard(string $retourPrevu, string $aujourdhui, int $attendu): void
{
$this->assertSame($attendu, joursDeRetard($retourPrevu, $aujourdhui));
}
}
Du 30 mars au 2 avril : 31 mars, 1er avril, 2 avril, soit 3 jours de retard. Les deux premiers jeux encadrent la limite « pas encore en retard » ; le troisième est la première valeur en retard. Si la fonction n'est pas dans une classe, il faut aussi la charger, par exemple avec l'entrée "files": ["src/fonctions.php"] de l'autoload de Composer.
Tu avais trouvé ?
Exercice 2 sur 3 assertEquals ou assertSame ? Réussi À revoir
Une fonction compterReservations() renvoie, à cause d'un bug, la chaîne '2' au lieu de l'entier 2 (la valeur vient de PDO sans conversion). Lequel de ces deux tests le détecte ? Pourquoi ce bug peut-il gêner plus tard ?
$this->assertEquals(2, $depot->compterReservations('PC-07'));
$this->assertSame(2, $depot->compterReservations('PC-07'));
Voir la réponseMasquer la réponse de l'exercice 2
Seul assertSame échoue : il compare comme ===, et '2' === 2 est faux. assertEquals compare comme == et considère que '2' vaut 2.
Le bug peut gêner plus tard parce que le type faux se propage : avec declare(strict_types=1), passer '2' à une fonction qui attend un int lève une TypeError ; en JSON, l'API renverra "2" au lieu de 2, et le front qui fait nombre + 1 obtiendra "21". Le test strict attrape le défaut à la source. Avec PDO, on évite ce problème en convertissant explicitement ((int) $ligne['nb']).
Tu avais trouvé ?
Exercice 3 sur 3 Les tests ne sont pas trouvés Réussi À revoir
Une élève lance vendor/bin/phpunit et obtient No tests executed!. Sa classe :
class ReservationTests extends TestCase
{
/** @test */
public function accepte_un_materiel_libre(): void { /* … */ }
}
Donne deux raisons possibles et la correction.
Voir la réponseMasquer la réponse de l'exercice 3
- L'annotation
@testdans un commentaire n'est plus lue depuis PHPUnit 12. Il faut l'attribut#[Test](avecuse PHPUnit\Framework\Attributes\Test;), ou un nom de méthode qui commence partest. - Le nom du fichier ou du dossier : par défaut, PHPUnit cherche dans le dossier indiqué par
phpunit.xmlles fichiers dont le nom se termine parTest.php. Un fichierReservationTests.php(avec un « s ») ou placé hors detests/n'est pas lu.
Correction : renommer la classe et le fichier en ReservationTest, placer le fichier dans tests/, et marquer la méthode :
use PHPUnit\Framework\Attributes\Test;
final class ReservationTest extends TestCase
{
#[Test]
public function accepteUnMaterielLibre(): void { /* … */ }
}
Tu avais trouvé ?