Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 :

bash
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 :

json
{
    "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
<?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
<?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
<?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émentRôle
extends TestCaseDonne accès aux assertions et au cycle de vie des tests
final class …TestPar 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

AssertionVé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.

php
    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 :

php
    #[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
<?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
<?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

text
$ 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 :

text
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 :

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
<?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 ?

php
$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 :

php
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
  1. L'annotation @test dans un commentaire n'est plus lue depuis PHPUnit 12. Il faut l'attribut #[Test] (avec use PHPUnit\Framework\Attributes\Test;), ou un nom de méthode qui commence par test.
  2. Le nom du fichier ou du dossier : par défaut, PHPUnit cherche dans le dossier indiqué par phpunit.xml les fichiers dont le nom se termine par Test.php. Un fichier ReservationTests.php (avec un « s ») ou placé hors de tests/ n'est pas lu.

Correction : renommer la classe et le fichier en ReservationTest, placer le fichier dans tests/, et marquer la méthode :

php
use PHPUnit\Framework\Attributes\Test;

final class ReservationTest extends TestCase
{
    #[Test]
    public function accepteUnMaterielLibre(): void { /* … */ }
}

Tu avais trouvé ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Mock, stub, spy : les doublures de testRevoir le sommaire du cours