Skip to content

11. Exercice d'application – version 4

L'application de calcul de l'impôt va maintenant implémenter la structure en couches suivante :

Image

Nous allons reprendre les éléments de la version 3 en les modifiant pour les adapter à la nouvelle architecture de l'application. On appelle cela du « refactoring » (réusinage du code). Nous supposons ici que les données nécessaires à l'application sont dans des fichiers texte. C'est la couche [dao] qui va s'occuper des échanges avec ces fichiers.

11.1. Arborescence des scripts

La version 4 est dans le dossier [scripts-console/impots/version-04] :

version-04
├── Tests
│   ├── data
│   │   ├── cle-manquante.json
│   │   ├── syntaxe.json
│   │   └── valeur-negative.json
│   ├── DaoTest.php
│   └── MetierTest.php
├── DaoImpotsWithTaxAdminDataInJsonFile.php
├── errors.txt
├── ExceptionImpots.php
├── InterfaceDao.php
├── InterfaceMetier.php
├── main.php
├── Metier.php
├── resultats.txt
├── taxadmindata.json
├── TaxAdminData.php
├── TaxPayerData.php
├── taxpayersdata.txt
├── TraitDao.php
└── Utilitaires.php

Le dossier [Tests] contient les tests automatisés de la version 4, que nous présenterons en fin de chapitre.

11.2. Objets échangés entre couches

Nous gardons certains éléments de la version 3, à l'identique :

  • l'exception [ExceptionImpots] est l'exception que lancera la couche [dao] lorsqu'elle rencontrera un problème soit avec l'accès aux données soit avec la nature des données (données incorrectes) ;
  • la classe [Utilitaires] rassemble des méthodes utiles à la gestion des fichiers texte (ici une seule méthode, [cutNewLineChar]) ;
  • la classe [readonly] [TaxAdminData] encapsule les données de l'administration fiscale. On l'obtient avec la méthode statique [TaxAdminData::fromJsonFile($fichier)] (ou [TaxAdminData::fromArray($tableau)]), qui vérifie la validité des données et lance une [ExceptionImpots] si elles sont incorrectes .

Nous ajoutons une nouvelle classe [TaxPayerData] qui encapsule les données d'un contribuable et les résultats du calcul de son impôt [TaxPayerData.php] :

<?php

declare(strict_types=1);

// espace de noms
namespace Application;

// les données d'un contribuable et les résultats du calcul de son impôt : un objet immuable
final readonly class TaxPayerData
{
    public function __construct(
        // données nécessaires au calcul de l'impôt du contribuable
        public string $marié,
        public int $enfants,
        public int $salaire,
        // résultats du calcul de l'impôt (null tant que l'impôt n'a pas été calculé)
        public ?int $impôt = null,
        public ?int $surcôte = null,
        public ?int $décôte = null,
        public ?int $réduction = null,
        public ?float $taux = null,
    ) {
    }

    // rend une copie de l'objet complétée par les résultats du calcul de l'impôt
    // $résultats : tableau [impôt, surcôte, décôte, réduction, taux] rendu par la couche [métier]
    public function avecRésultats(array $résultats): static
    {
        return clone($this, [
            "impôt" => $résultats["impôt"],
            "surcôte" => $résultats["surcôte"],
            "décôte" => $résultats["décôte"],
            "réduction" => $résultats["réduction"],
            "taux" => $résultats["taux"],
        ]);
    }

    // chaîne JSON de l'objet
    public function __toString(): string
    {
        return json_encode(get_object_vars($this), JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
    }
}

Commentaires

  • ligne 9 : comme [TaxAdminData], la classe [TaxPayerData] est immuable : ses objets passent d'une couche à l'autre sans risque d'être modifiés en chemin ;
  • lignes 13-15 : les données du contribuable nécessaires au calcul de son impôt ;
  • lignes 17-21 : les résultats du calcul de l'impôt. Ils sont de type nullable ([?int], [?float]) avec la valeur par défaut [null] : un objet [TaxPayerData] est d'abord créé par la couche [dao] avec les seules données du contribuable [new TaxPayerData("oui", 2, 55555)], l'impôt n'étant pas encore calculé. Les noms des attributs sont ceux des clés JSON du fichier des résultats de la version 3 : le fichier [resultats.txt] gardera ainsi le même format ;
  • lignes 27-36 : l'objet étant immuable, on ne peut pas lui ajouter les résultats du calcul. La méthode [avecRésultats] rend une copie de l'objet complétée par les résultats. Elle utilise l'opérateur [clone] de PHP 8.5, qui accepte en second paramètre un tableau des attributs à modifier dans la copie (voir le chapitre sur les classes). Le cours PHP 7 utilisait un objet modifiable avec huit getters et huit setters ;
  • lignes 39-42 : la méthode [__toString] rend la chaîne JSON de l'objet. Elle servira à écrire le fichier des résultats .

11.3. La couche [dao]

Nous nous intéressons ici à la couche [1] de notre application :

Image

11.3.1. L'interface [InterfaceDao]

L'interface de la couche [dao] sera la suivante [InterfaceDao.php] :

<?php

declare(strict_types=1);

// espace de noms
namespace Application;

// l'interface de la couche [dao]
// toutes les méthodes lancent une exception [ExceptionImpots] en cas d'erreur irrécupérable
interface InterfaceDao
{
    // lecture des données contribuables
    // les lignes erronées sont signalées dans le fichier texte [$errorsFilename]
    /** @return TaxPayerData[] */
    public function getTaxPayersData(string $taxPayersFilename, string $errorsFilename): array;

    // lecture des données de l'administration fiscale (tranches d'impôt et constantes)
    public function getTaxAdminData(): TaxAdminData;

    // enregistrement des résultats
    /** @param TaxPayerData[] $taxPayersData */
    public function saveResults(string $resultsFilename, array $taxPayersData): void;
}

Commentaires

  • le cahier des charges est ici le suivant :
    • les données des contribuables sont dans un fichier texte ;
    • on enregistre les résultats du calcul de l'impôt dans un fichier texte ;
    • on enregistre les éventuelles erreurs dans un fichier texte ;
    • on ne sait pas sous quelle forme sont disponibles les données de l'administration fiscale. Pour chaque nouvelle forme, l'interface [InterfaceDao] devra être implémentée par une nouvelle classe ;
    • les méthodes de l'interface qui rencontrent une erreur irrécupérable lors de l'accès aux données doivent lancer une exception de type [ExceptionImpots] ;
  • ligne 15 : la méthode qui permet d'obtenir les données des contribuables [statut marital, nombre d'enfants, salaire annuel] :
    • le 1er paramètre est le nom du fichier texte dans lequel se trouvent ces données ;
    • le second paramètre est le nom du fichier texte dans lequel enregistrer les éventuelles erreurs rencontrées ;
  • ligne 14 : le type [array] ne dit pas ce que contient le tableau. Le commentaire de documentation [/** @return TaxPayerData[] */] (syntaxe PHPDoc) précise qu'il s'agit d'un tableau d'objets [TaxPayerData]. PHP l'ignore, mais l'extension [PHP Intelephense] de [VS Code] l'utilise : elle connaît alors le type des éléments du tableau et peut proposer leurs attributs à la saisie ;
  • ligne 18 : la méthode qui permet d'obtenir les données de l'administration fiscale. On ne lui passe aucun paramètre car on ne sait pas comment elles sont stockées ;
  • ligne 22 : la méthode qui permet d'enregistrer les résultats du calcul de l'impôt dans un fichier texte dont on passe le nom en paramètre .

Lorsqu'on écrit l'interface [InterfaceDao], on sait qu'il y aura différentes façons d'écrire la méthode [getTaxAdminData] selon la façon dont seront stockées les données de l'administration fiscale. L'interface [InterfaceDao] sera donc implémentée par différentes classes, chacune s'occupant d'un stockage particulier de ces données (fichier JSON, base de données, service web). Ces classes auront néanmoins un code commun, celui de l'implémentation des méthodes [getTaxPayersData] et [saveResults]. Nous avons vu au chapitre sur les traits que ce cas d'utilisation peut être implémenté de deux façons :

  • on crée une classe abstraite C qui regroupe le code commun aux classes dérivées. La classe C implémente l'interface I mais certaines méthodes, qui doivent être définies dans les classes dérivées, sont déclarées abstraites dans la classe C, qui est donc elle-même abstraite. On crée ensuite des classes C1 et C2 dérivées de C qui implémentent chacune à leur manière les méthodes abstraites de leur classe parent C ;
  • on crée un trait T quasi identique à la classe abstraite C de la solution précédente. Ce trait n'implémente pas l'interface I car syntaxiquement il ne le peut pas. On crée ensuite des classes C1 et C2 implémentant l'interface I et utilisant le trait T. Il ne reste à ces classes qu'à implémenter les méthodes de l'interface I non implémentées par le trait T qu'elles importent .

Pour l'exemple, nous allons utiliser ici un trait [TraitDao]. La version 3 utilisait une classe abstraite.

11.3.2. Le trait [TraitDao]

Le code du trait [TraitDao] est le suivant [TraitDao.php] :

<?php

declare(strict_types=1);

// espace de noms
namespace Application;

// implémentation des méthodes de [InterfaceDao] qui travaillent avec des fichiers texte
trait TraitDao
{
    // séparateur des champs d'une ligne du fichier des contribuables (constante de trait, PHP 8.2)
    private const string SEPARATEUR = ",";

    // lecture des données contribuables
    #[\Override]
    public function getTaxPayersData(string $taxPayersFilename, string $errorsFilename): array
    {
        // tableau des données contribuables
        $taxPayersData = [];
        // tableau des erreurs
        $errors = [];
        // ouverture du fichier des données contribuables
        $taxPayersFile = is_readable($taxPayersFilename) ? fopen($taxPayersFilename, "r") : false;
        if ($taxPayersFile === false) {
            throw new ExceptionImpots("Impossible d'ouvrir en lecture les déclarations des contribuables [$taxPayersFilename]", 12);
        }
        // le fichier est ouvert : on devra le fermer quoi qu'il arrive
        try {
            // on exploite la ligne courante du fichier des données contribuables
            // qui a la forme statut marital, nombre d'enfants, salaire annuel
            $num = 0;  // n° ligne courante
            while (($ligne = fgets($taxPayersFile, 100)) !== false) {
                // une ligne de plus
                $num++;
                // on enlève l'éventuelle marque de fin de ligne
                $ligne = Utilitaires::cutNewLineChar($ligne);
                // on ignore les lignes vides
                if (trim($ligne) === "") {
                    continue;
                }
                // la ligne doit avoir 3 champs marié,enfants,salaire
                $champs = array_map(trim(...), explode(self::SEPARATEUR, $ligne));
                $erreur = count($champs) !== 3;
                if (!$erreur) {
                    [$marié, $enfants, $salaire] = $champs;
                    // le statut marital doit être oui ou non
                    $marié = strtolower($marié);
                    // le nombre d'enfants et le salaire doivent être des entiers positifs ou nuls
                    $erreur = !in_array($marié, ["oui", "non"], true)
                        || !preg_match('/^\d+$/', $enfants)
                        || !preg_match('/^\d+$/', $salaire);
                }
                // erreur ?
                if ($erreur) {
                    $errors[] = "la ligne [$num] du fichier [$taxPayersFilename] est erronée";
                } else {
                    // on mémorise les données du contribuable
                    $taxPayersData[] = new TaxPayerData($marié, (int) $enfants, (int) $salaire);
                }
            }
            // est-on à la fin du fichier ?
            if (!feof($taxPayersFile)) {
                // on est sorti de la boucle sur une erreur de lecture
                throw new ExceptionImpots("Erreur lors de la lecture de la ligne n° [" . ($num + 1) . "] du fichier [$taxPayersFilename]", 13);
            }
        } finally {
            // on ferme le fichier
            fclose($taxPayersFile);
        }
        // on sauvegarde les erreurs dans un fichier texte
        $this->saveLines($errorsFilename, $errors);
        // résultat de la fonction
        return $taxPayersData;
    }

    // enregistrement des résultats
    #[\Override]
    public function saveResults(string $resultsFilename, array $taxPayersData): void
    {
        // chaque élément [TaxPayerData] est transformé en chaîne JSON par sa méthode [__toString]
        $this->saveLines($resultsFilename, array_map(strval(...), $taxPayersData));
    }

    // enregistrement d'un tableau de lignes dans un fichier texte
    private function saveLines(string $fileName, array $lignes): void
    {
        // si le fichier texte [$fileName] n'existe pas, il est créé, sinon il est écrasé
        $data = implode("", array_map(fn(string $ligne): string => "$ligne\n", $lignes));
        if (@file_put_contents($fileName, $data) === false) {
            throw new ExceptionImpots("Erreur lors de l'enregistrement de données dans le fichier texte [$fileName]", 14);
        }
    }
}

Commentaires

  • ligne 9 : nous définissons ici un trait et non une classe ;
  • ligne 12 : une constante de trait (PHP 8.2), typée (PHP 8.3) : le séparateur des champs d'une ligne du fichier des contribuables. Elle devient une constante de la classe qui utilise le trait et s'utilise avec la notation [self::SEPARATEUR] ;
  • ligne 15 : depuis PHP 8.3, l'attribut [#[\Override]] est accepté sur une méthode de trait. Il est vérifié dans la classe qui utilise le trait : ici la méthode doit correspondre à une méthode de l'interface [InterfaceDao] implémentée par cette classe ;
  • lignes 16-74 : la méthode [getTaxPayersData] implémente la méthode de même nom de l'interface [InterfaceDao]. Elle récupère dans le fichier texte nommé [$taxPayersFilename] les données des contribuables [statut marital, nombre d'enfants, salaire annuel]. Elle les rend sous la forme d'un tableau [$taxPayersData] d'éléments de type [TaxPayerData] (ligne 58) ;
  • la méthode [getTaxPayersData] est très semblable à la méthode [AbstractBaseImpots::executeBatchImpots] de la version 3, avec les différences suivantes :
    • la méthode [getTaxPayersData] ne fait que récupérer les données des contribuables. Elle ne fait pas de calcul d'impôt : c'est le rôle de la couche [métier] ;
    • comme le faisait la méthode [executeBatchImpots], elle signale les lignes erronées. Ici, les erreurs sont d'abord mémorisées dans un tableau [$errors], tableau qui est enregistré dans un fichier texte à la fin du traitement (ligne 71). Selon les cas, il est vide ou non. Une ligne erronée ne provoque plus d'exception : c'est au code appelant de consulter le fichier des erreurs ;
    • dans le cas d'erreur irrécupérable, une exception de type [ExceptionImpots] est lancée (ligne 25, ligne 64) ;
  • lignes 23-26 : le fichier est ouvert avant la structure [try / finally]. Si l'ouverture échoue, on lance une exception et il n'y a rien à fermer. Si elle réussit, la clause [finally] (lignes 66-69) garantit la fermeture du fichier, qu'une exception se produise ou non. Cette organisation évite le test « le fichier a-t-il été ouvert ? » de la version 3 ;
  • lignes 62-65 : on notera le traitement fait à la sortie de la boucle de lecture. La fonction [fgets] a l'inconvénient de rendre [false] aussi bien lorsque la lecture a rencontré la fin du fichier que lorsque la lecture n'a pu aboutir à cause d'une erreur. Pour différencier les deux cas, on teste si on est arrivé à la fin du fichier avec la fonction [feof]. Si ce n'est pas le cas, c'est qu'une erreur s'est produite et on lance alors une exception ;
  • lignes 78-82 : implémentation de la méthode [saveResults] de l'interface [InterfaceDao]. Chaque élément [TaxPayerData] du tableau est transformé en chaîne de caractères par la fonction [strval], qui appelle la méthode [__toString] de l'objet : on obtient sa chaîne JSON. [strval(...)] désigne la fonction [strval] elle-même (callable de première classe, PHP 8.1) ;
  • lignes 85-92 : la méthode privée [saveLines] enregistre un tableau de lignes dans un fichier texte, chaque ligne étant terminée par la marque de fin de ligne \n ;
  • ligne 89 : la fonction PHP [file_put_contents] enregistre une chaîne de caractères dans un fichier texte. Elle s'occupe d'ouvrir le fichier, d'écrire la chaîne dedans et de fermer le fichier. Elle rend [false] si une erreur s'est produite. Dans ce cas, PHP émet également un avertissement. L'opérateur [@] supprime cet avertissement : l'erreur est signalée par l'exception lancée à la ligne suivante, avec un message plus clair .

Le trait [TraitDao] a implémenté deux des méthodes de l'interface [InterfaceDao], [getTaxPayersData] et [saveResults]. Il nous reste à implémenter la méthode [getTaxAdminData] qui récupère les données de l'administration fiscale.

11.3.3. La classe [DaoImpotsWithTaxAdminDataInJsonFile]

La classe [DaoImpotsWithTaxAdminDataInJsonFile] implémente l'interface [InterfaceDao] de la façon suivante :

<?php

declare(strict_types=1);

// espace de noms
namespace Application;

// une couche [dao] qui trouve les données de l'administration fiscale dans un fichier JSON
class DaoImpotsWithTaxAdminDataInJsonFile implements InterfaceDao
{
    // le trait implémente les méthodes [getTaxPayersData] et [saveResults] de l'interface
    use TraitDao;

    // l'objet de type [TaxAdminData] qui contient les données de l'administration fiscale
    private readonly TaxAdminData $taxAdminData;

    // le constructeur
    public function __construct(string $taxAdminDataFilename)
    {
        // peut lancer une exception [ExceptionImpots]
        $this->taxAdminData = TaxAdminData::fromJsonFile($taxAdminDataFilename);
    }

    // rend les données permettant le calcul de l'impôt
    #[\Override]
    public function getTaxAdminData(): TaxAdminData
    {
        return $this->taxAdminData;
    }
}

Commentaires

  • ligne 9 : la classe [DaoImpotsWithTaxAdminDataInJsonFile] implémente l'interface [InterfaceDao] ;
  • ligne 12 : la classe utilise le trait [TraitDao] qui, on le sait, implémente les méthodes [getTaxPayersData] et [saveResults] de l'interface [InterfaceDao]. Il ne reste donc plus à la classe qu'à implémenter la méthode [getTaxAdminData] qui récupère les données de l'administration fiscale ;
  • ligne 15 : l'attribut de type [TaxAdminData] que rend la méthode [getTaxAdminData]. Il est initialisé par le constructeur ;
  • ligne 21 : les données de l'administration fiscale sont lues dans le fichier JSON. Si le fichier est absent ou incorrect, une exception [ExceptionImpots] remonte au code appelant .

Nous en avons terminé avec la couche [dao] de notre application : nous avons une classe qui implémente totalement l'interface [InterfaceDao] que nous nous sommes imposée. Nous pouvons désormais passer à la couche [métier].

11.4. La couche [métier]

Nous allons maintenant implémenter la couche [2] de notre architecture :

Image

11.4.1. L'interface [InterfaceMetier]

L'interface de la couche [métier] sera la suivante [InterfaceMetier.php] :

<?php

declare(strict_types=1);

// espace de noms
namespace Application;

// l'interface de la couche [métier]
interface InterfaceMetier
{
    // calcul de l'impôt d'un contribuable
    // rend le tableau [impôt, surcôte, décôte, réduction, taux]
    public function calculerImpot(string $marié, int $enfants, int $salaire): array;

    // calcul des impôts en mode batch
    // peut lancer une exception [ExceptionImpots] remontant de la couche [dao]
    public function executeBatchImpots(string $taxPayersFileName, string $resultsFileName, string $errorsFileName): void;
}

Commentaires

  • ligne 13 : la couche [métier] sait calculer le montant de l'impôt d'un contribuable particulier pourvu qu'on lui donne les informations suivantes : statut marital, nombre d'enfants, salaire annuel. La méthode [calculerImpot] est une méthode purement « métier » : elle ne se préoccupe pas d'où viennent les données qu'elle utilise. Elle n'utilise pas la couche [dao] aussi ne lance-t-elle pas d'exception ;
  • ligne 17 : la couche [métier] peut aussi calculer le montant de l'impôt d'un ensemble de contribuables dont les données sont rassemblées dans le fichier texte nommé [$taxPayersFileName]. Elle met les résultats dans le fichier texte nommé [$resultsFileName]. Les erreurs non fatales sont enregistrées dans le fichier texte nommé [$errorsFileName]. La méthode [executeBatchImpots] doit s'adresser à la couche [dao] qui s'occupe des accès au système de fichiers. Des exceptions peuvent alors remonter de la couche [dao] : la méthode [executeBatchImpots] ne les interceptera pas, elle les laissera remonter au script principal .

11.4.2. La classe [Metier]

La classe [Metier] implémente l'interface [InterfaceMetier] de la façon suivante :

<?php

declare(strict_types=1);

// espace de noms
namespace Application;

// la couche [métier]
class Metier implements InterfaceMetier
{
    // données de l'administration fiscale
    private readonly TaxAdminData $taxAdminData;

    // la couche [dao] est injectée par le constructeur
    public function __construct(private readonly InterfaceDao $dao)
    {
        // on récupère les données permettant le calcul de l'impôt
        // la méthode [getTaxAdminData] peut lancer une exception [ExceptionImpots]
        // on la laisse alors remonter au code appelant
        $this->taxAdminData = $this->dao->getTaxAdminData();
    }

    // calcul de l'impôt
    // --------------------------------------------------------------------------
    #[\Override]
    public function calculerImpot(string $marié, int $enfants, int $salaire): array
    {
        // $marié : oui, non
        // $enfants : nombre d'enfants
        // $salaire : salaire annuel
        // $this->taxAdminData : données de l'administration fiscale
        //
        // calcul de l'impôt avec enfants
        $result1 = $this->calculerImpot2($marié, $enfants, $salaire);
        $impot1 = $result1["impôt"];
        // calcul de l'impôt sans les enfants
        if ($enfants !== 0) {
            $result2 = $this->calculerImpot2($marié, 0, $salaire);
            $impot2 = $result2["impôt"];
            // application du plafonnement du quotient familial
            $plafondDemiPart = $this->taxAdminData->plafondQfDemiPart;
            if ($enfants < 3) {
                // plafondQfDemiPart euros pour les 2 premiers enfants
                $impot2 = $impot2 - $enfants * $plafondDemiPart;
            } else {
                // plafondQfDemiPart euros pour les 2 premiers enfants, le double pour les suivants
                $impot2 = $impot2 - 2 * $plafondDemiPart - ($enfants - 2) * 2 * $plafondDemiPart;
            }
        } else {
            $impot2 = $impot1;
            $result2 = $result1;
        }
        // on prend l'impôt le plus fort avec le taux et la surcôte qui vont avec
        if ($impot1 > $impot2) {
            $impot = $impot1;
            $taux = $result1["taux"];
            $surcôte = $result1["surcôte"];
        } else {
            $surcôte = $impot2 - $impot1 + $result2["surcôte"];
            $impot = $impot2;
            $taux = $result2["taux"];
        }
        // calcul d'une éventuelle décôte
        $décôte = $this->getDecote($marié, $salaire, $impot);
        $impot -= $décôte;
        // calcul d'une éventuelle réduction d'impôts
        $réduction = $this->getRéduction($marié, $salaire, $enfants, $impot);
        $impot -= $réduction;
        // résultat
        return [
            "impôt" => (int) floor($impot),
            "surcôte" => (int) $surcôte,
            "décôte" => (int) $décôte,
            "réduction" => (int) $réduction,
            "taux" => $taux,
        ];
    }

Commentaires

  • ligne 9 : la classe [Metier] implémente l'interface [InterfaceMetier], c'est-à-dire les méthodes [calculerImpot] et [executeBatchImpots] ;
  • ligne 12 : l'objet de type [TaxAdminData] qui encapsule les données de l'administration fiscale. Ces données sont nécessaires à la méthode métier [calculerImpot] ;
  • lignes 15-21 : le constructeur initialise les deux attributs de la classe :
    • l'attribut [$dao], une référence sur la couche [dao], est initialisé par promotion du paramètre du constructeur. Il en faut obligatoirement une pour que la couche [métier] sache à qui s'adresser lorsqu'elle veut des données externes. On notera que le type de ce paramètre est celui de l'interface [InterfaceDao], permettant ainsi à la classe [Metier] de travailler avec n'importe quelle classe implémentant cette interface. Le cours PHP 7 offrait en plus un setter [setDao] : il disparaît, la couche [dao] étant injectée une fois pour toutes par le constructeur ;
    • l'attribut [$taxAdminData] est initialisé en faisant appel à la méthode [getTaxAdminData] de la couche [dao] ;
  • on en conclut que lorsque les méthodes [calculerImpot] et [executeBatchImpots] s'exécutent, les deux attributs [$dao] et [$taxAdminData] sont initialisés. Le test [$this->taxAdminData ??= $this->getTaxAdminData()] de la version 3 n'a plus lieu d'être ;
  • lignes 26-77 : ce code est celui de la méthode [AbstractBaseImpots::calculerImpot] de la version 3. Il en est de même pour les méthodes privées [calculerImpot2], [getRevenuImposable], [getDecote] et [getRéduction], qui ne sont pas reproduites ici .

La méthode [Metier::executeBatchImpots] est la suivante :

    // calcul des impôts en mode batch
    // --------------------------------------------------------------------------
    #[\Override]
    public function executeBatchImpots(string $taxPayersFileName, string $resultsFileName, string $errorsFileName): void
    {
        // on laisse remonter les exceptions qui proviennent de la couche [dao]
        // on récupère les données contribuables
        $taxPayersData = $this->dao->getTaxPayersData($taxPayersFileName, $errorsFileName);
        // on calcule l'impôt de chacun des contribuables
        $results = array_map(
            fn(TaxPayerData $taxPayerData): TaxPayerData => $taxPayerData->avecRésultats(
                $this->calculerImpot($taxPayerData->marié, $taxPayerData->enfants, $taxPayerData->salaire)
            ),
            $taxPayersData
        );
        // enregistrement des résultats
        $this->dao->saveResults($resultsFileName, $results);
    }
}

Commentaires

  • ligne 188 : la méthode doit appeler de façon répétée la méthode [calculerImpot] pour chacun des contribuables trouvés dans le fichier texte nommé [$taxPayersFileName] et mettre les résultats dans le fichier texte nommé [$resultsFileName]. Les erreurs non fatales rencontrées sont enregistrées dans le fichier texte nommé [$errorsFileName]. La méthode ne lance pas d'exception elle-même mais laisse remonter celles que la couche [dao] lance ;
  • ligne 192 : les données des contribuables sont demandées à la couche [dao]. Celle-ci rend un tableau d'éléments de type [TaxPayerData]. S'il se produit une exception ici, comme elle n'est pas interceptée par un [catch], elle remonte automatiquement au code appelant et la suite de la méthode n'est pas exécutée ;
  • lignes 194-199 : la fonction [array_map] applique une fonction à chacun des éléments du tableau [$taxPayersData] et rend le tableau des résultats. La fonction appliquée est une fonction fléchée qui calcule l'impôt du contribuable [$taxPayerData] et rend une copie de [$taxPayerData] complétée par les résultats du calcul (ligne 195). On remarquera que la fonction fléchée utilise [$this] : une fonction fléchée capture automatiquement les variables de son contexte, [$this] compris ;
  • ligne 201 : une fois l'impôt calculé pour tous les contribuables, les résultats sont enregistrés dans un fichier texte. C'est la couche [dao] qui fait ce travail .

En général, la couche [métier] est assez simple à écrire car elle s'adresse à la couche [dao] qui, elle, gère l'accès aux données avec la gestion des erreurs qui va avec.

11.5. Le script principal

On écrit maintenant le script de la couche [3] de notre architecture :

Image

Le script principal est le suivant [main.php] :

<?php

// respect strict des types déclarés des paramètres et des résultats des fonctions
declare(strict_types=1);

// espace de noms
namespace Application;

// inclusion des interfaces et classes
require_once __DIR__ . "/ExceptionImpots.php";
require_once __DIR__ . "/Utilitaires.php";
require_once __DIR__ . "/TaxAdminData.php";
require_once __DIR__ . "/TaxPayerData.php";
require_once __DIR__ . "/InterfaceDao.php";
require_once __DIR__ . "/TraitDao.php";
require_once __DIR__ . "/DaoImpotsWithTaxAdminDataInJsonFile.php";
require_once __DIR__ . "/InterfaceMetier.php";
require_once __DIR__ . "/Metier.php";

// définition des constantes
const TAXPAYERSDATA_FILENAME = "taxpayersdata.txt";
const RESULTS_FILENAME = "resultats.txt";
const ERRORS_FILENAME = "errors.txt";
const TAXADMINDATA_FILENAME = "taxadmindata.json";

try {
    // création de la couche [dao]
    $dao = new DaoImpotsWithTaxAdminDataInJsonFile(TAXADMINDATA_FILENAME);
    // création de la couche [métier]
    $métier = new Metier($dao);
    // calcul de l'impôt en mode batch
    $métier->executeBatchImpots(TAXPAYERSDATA_FILENAME, RESULTS_FILENAME, ERRORS_FILENAME);
    print "Les résultats ont été enregistrés dans le fichier [" . RESULTS_FILENAME . "]\n";
} catch (ExceptionImpots $ex) {
    // on affiche l'erreur
    print "Erreur n° {$ex->getCode()} : {$ex->getMessage()}\n";
}
// fin
print "Terminé\n";

Commentaires

  • lignes 10-18 : chargement des classes, interfaces et trait de l'application. Un trait doit être chargé avant la classe qui l'utilise ;
  • ligne 21 : le nom du fichier des données contribuables ;
  • ligne 22 : le nom du fichier des résultats ;
  • ligne 23 : le nom du fichier des erreurs ;
  • ligne 24 : le nom du fichier JSON contenant les données de l'administration fiscale ;
  • ligne 28 : création de la couche [dao] ;
  • ligne 30 : création de la couche [métier] s'appuyant sur cette couche [dao] ;
  • ligne 32 : exécution de la méthode [executeBatchImpots] de la couche [métier] ;
  • lignes 34-37 : on a vu que les couches [dao] et [métier] pouvaient faire remonter des exceptions. Elles sont interceptées ici .

11.6. Tests visuels

11.6.1. Test n° 1

On exécute le script [main.php] dans un terminal ouvert dans le dossier [version-04] :

1
2
3
> php main.php
Les résultats ont été enregistrés dans le fichier [resultats.txt]
Terminé

Avec le fichier des contribuables [taxpayersdata.txt] de la version 3 :

oui,2,55555
oui,2,50000
oui,3,50000
non,2,100000
non,3x,100000
non,3,100000
oui,3,100000
oui,5,100000x
oui,5,100000
non,0,100000
oui,2,30000
non,0,200000
oui,3,200000

on obtient le fichier des erreurs [errors.txt] suivant :

la ligne [5] du fichier [taxpayersdata.txt] est erronée
la ligne [8] du fichier [taxpayersdata.txt] est erronée

et le fichier des résultats [resultats.txt] suivant :

{"marié":"oui","enfants":2,"salaire":55555,"impôt":2814,"surcôte":0,"décôte":0,"réduction":0,"taux":0.14}
{"marié":"oui","enfants":2,"salaire":50000,"impôt":1384,"surcôte":0,"décôte":384,"réduction":347,"taux":0.14}
{"marié":"oui","enfants":3,"salaire":50000,"impôt":0,"surcôte":0,"décôte":720,"réduction":0,"taux":0.14}
{"marié":"non","enfants":2,"salaire":100000,"impôt":19884,"surcôte":4480,"décôte":0,"réduction":0,"taux":0.41}
{"marié":"non","enfants":3,"salaire":100000,"impôt":16782,"surcôte":7176,"décôte":0,"réduction":0,"taux":0.41}
{"marié":"oui","enfants":3,"salaire":100000,"impôt":9200,"surcôte":2180,"décôte":0,"réduction":0,"taux":0.3}
{"marié":"oui","enfants":5,"salaire":100000,"impôt":4230,"surcôte":0,"décôte":0,"réduction":0,"taux":0.14}
{"marié":"non","enfants":0,"salaire":100000,"impôt":22986,"surcôte":0,"décôte":0,"réduction":0,"taux":0.41}
{"marié":"oui","enfants":2,"salaire":30000,"impôt":0,"surcôte":0,"décôte":0,"réduction":0,"taux":0}
{"marié":"non","enfants":0,"salaire":200000,"impôt":64210,"surcôte":7498,"décôte":0,"réduction":0,"taux":0.45}
{"marié":"oui","enfants":3,"salaire":200000,"impôt":42842,"surcôte":17283,"décôte":0,"réduction":0,"taux":0.41}

Ce sont les résultats de la version 3.

11.6.2. Test n° 2

Dans le script principal, on met pour le fichier des contribuables un nom de fichier qui n'existe pas :

const TAXPAYERSDATA_FILENAME = "taxpayersdata2.txt";

Les résultats obtenus à la console sont alors les suivants :

1
2
3
> php main.php
Erreur n° 12 : Impossible d'ouvrir en lecture les déclarations des contribuables [taxpayersdata2.txt]
Terminé

On n'obtient que le message de l'exception lancée par la couche [dao]. Dans le cours PHP 7, la fonction [fopen] était appelée sur le fichier inexistant : l'interpréteur PHP affichait alors un avertissement avant le message de l'exception :

Warning: fopen(taxpayersdata2.txt): failed to open stream: No such file or directory in ...

Le cours PHP 7 faisait disparaître ce message en demandant à PHP de ne pas afficher les erreurs avec l'instruction [ini_set("display_errors", "0")]. C'est la bonne configuration d'un serveur en production (les erreurs sont alors écrites dans un fichier de logs), mais pendant le développement il faut voir tous les messages de PHP. Nous préférons donc ne pas provoquer l'avertissement : la couche [dao] vérifie avec la fonction [is_readable] que le fichier peut être lu avant de l'ouvrir.

11.7. Tests [PHPUnit]

Les tests visuels sont très insuffisants :

  • on se limite en général à quelques tests ;
  • on est plus ou moins attentif lors de cette vérification visuelle et des détails peuvent nous échapper .

Dans la réalité du développement professionnel, les tests sont écrits sous forme de code et exécutés automatiquement, souvent après chaque modification du code. On cherche à faire les tests les plus complets possible. Pour cela, on utilise des frameworks de test.

Nous utiliserons ici [PHPUnit] [https://phpunit.de/], le framework de tests de référence en PHP. Le cours PHP 7 utilisait le framework [Codeception], choisi parce qu'il s'intégrait à l'IDE NetBeans, et qui utilisait d'ailleurs PHPUnit en sous-main. Nous n'utiliserons qu'une petite partie des possibilités de PHPUnit. L'idée est d'avoir un moyen rapide, après chaque nouvelle version de l'exercice d'application, de vérifier que celle-ci fonctionne. L'existence de tests réussis donne au développeur confiance dans le code qu'il a écrit. C'est un facteur important.

11.7.1. Installation de [PHPUnit]

Comme beaucoup de bibliothèques PHP, [PHPUnit] s'installe avec [Composer], le gestionnaire de dépendances de PHP, fourni avec [Laragon]. Le dossier [php8-oct-2026] des codes du cours contient un fichier [composer.json] qui déclare les bibliothèques utilisées par le cours. PHPUnit y figure dans la section [require-dev], celle des bibliothèques utiles au seul développement :

1
2
3
"require-dev": {
    "phpunit/phpunit": "^12.0"
},

Si ce n'est déjà fait, il suffit d'ouvrir un terminal dans le dossier [php8-oct-2026] et de taper la commande :

composer install

[Composer] télécharge alors toutes les bibliothèques déclarées dans [composer.json] dans le dossier [vendor] de [php8-oct-2026]. PHPUnit y est installé avec son exécutable [vendor/bin/phpunit]. Dans un projet qui n'aurait pas encore PHPUnit, on l'ajouterait avec la commande [composer require --dev phpunit/phpunit], qui le télécharge et l'inscrit dans la section [require-dev] du fichier [composer.json].

La version 12 de PHPUnit nécessite PHP 8.3 ou plus. Sa documentation est disponible à l'adresse [https://docs.phpunit.de/].

11.7.2. Le fichier de configuration [phpunit.xml]

Le dossier [php8-oct-2026] contient également le fichier de configuration de PHPUnit [phpunit.xml]. Lorsqu'on lance PHPUnit depuis ce dossier, il utilise automatiquement ce fichier :

<?xml version="1.0" encoding="UTF-8"?>
<!-- configuration de PHPUnit pour tous les tests du cours :
     php vendor/bin/phpunit scripts-console/impots/version-04/Tests -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         colors="false"
         failOnDeprecation="true"
         failOnPhpunitDeprecation="true"
         failOnNotice="true"
         failOnWarning="true"
         displayDetailsOnTestsThatTriggerDeprecations="true"
         displayDetailsOnTestsThatTriggerNotices="true"
         displayDetailsOnTestsThatTriggerWarnings="true"
         cacheDirectory=".phpunit.cache">
    <source ignoreSuppressionOfDeprecations="true">
        <include>
            <directory>scripts-console</directory>
            <directory>scripts-web</directory>
        </include>
    </source>
</phpunit>

Commentaires

  • ligne 5 : le schéma XML qui décrit la syntaxe du fichier. [VS Code] l'utilise pour vérifier le fichier et proposer les attributs possibles ;
  • ligne 6 : pas de couleurs dans l'affichage des résultats. On peut mettre [true] pour avoir les tests réussis en vert et les échecs en rouge dans le terminal ;
  • lignes 7-10 : un test qui provoque une dépréciation, une remarque (notice) ou un avertissement (warning) de PHP est considéré comme un échec, même si ses assertions sont vérifiées. C'est une façon d'imposer un code PHP 8 propre : par exemple, l'utilisation d'une fonctionnalité dépréciée en PHP 8.5 ferait échouer les tests ;
  • lignes 11-13 : dans ces cas, PHPUnit affiche le détail du message de PHP ;
  • ligne 14 : le dossier où PHPUnit mémorise des informations d'une exécution à l'autre (par exemple les tests qui ont échoué, pour les exécuter en premier la fois suivante) ;
  • lignes 15-20 : les dossiers du code source testé. [ignoreSuppressionOfDeprecations] signifie qu'une dépréciation est signalée même si elle a été masquée par l'opérateur [@] .

11.7.3. Tests de la couche [dao]

Les tests de la version 4 sont dans le dossier [version-04/Tests]. Une classe de test PHPUnit :

  • étend la classe [PHPUnit\Framework\TestCase] ;
  • a un nom qui se termine par [Test], dans un fichier de même nom : c'est ainsi que PHPUnit trouve les classes de test d'un dossier ;
  • a des méthodes publiques de test dont le nom commence par [test] ;
  • peut avoir une méthode [setUp], exécutée avant chaque méthode de test, et une méthode [tearDown], exécutée après chaque méthode de test. Elles servent à préparer l'environnement du test puis à le nettoyer. Chaque méthode de test s'exécute ainsi dans un environnement neuf, indépendamment des autres .

Dans une méthode de test, on vérifie des résultats avec des méthodes d'assertion héritées de [TestCase], par exemple :

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 ([==])
[assertEqualsWithDelta($attendu, $obtenu, $delta)]
les deux nombres sont égaux à [$delta] près
[assertTrue($obtenu)], [assertFalse($obtenu)], [assertNull($obtenu)]
la valeur est [true], [false], [null]
[assertCount($n, $tableau)]
le tableau a [$n] éléments
[assertStringContainsString($sous, $chaîne)]
la chaîne contient la sous-chaîne
[expectException($classe)]
le code qui suit lance une exception de la classe indiquée

Pour construire les tests d'une version de l'exercice d'application, nous utilisons un environnement identique à celui du script principal [main.php] de la version : les mêmes fichiers chargés, les mêmes couches construites de la même façon.

La classe de test [DaoTest] est la suivante [Tests/DaoTest.php] :

<?php

// respect strict des types déclarés des paramètres et des résultats des fonctions
declare(strict_types=1);

// espace de noms des tests
namespace Application\Tests;

// les classes testées
use Application\DaoImpotsWithTaxAdminDataInJsonFile;
use Application\ExceptionImpots;
use Application\InterfaceDao;
use Application\TaxPayerData;
// les classes de PHPUnit
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

// chargement des classes de la couche [dao]
require_once __DIR__ . "/../ExceptionImpots.php";
require_once __DIR__ . "/../Utilitaires.php";
require_once __DIR__ . "/../TaxAdminData.php";
require_once __DIR__ . "/../TaxPayerData.php";
require_once __DIR__ . "/../InterfaceDao.php";
require_once __DIR__ . "/../TraitDao.php";
require_once __DIR__ . "/../DaoImpotsWithTaxAdminDataInJsonFile.php";

final class DaoTest extends TestCase
{
    // le dossier de la version 4
    private const string ROOT = __DIR__ . "/..";
    // la couche [dao] testée
    private InterfaceDao $dao;
    // un fichier temporaire pour les erreurs
    private string $errorsFilename;

    // exécutée avant chaque test
    #[\Override]
    protected function setUp(): void
    {
        // création de la couche [dao]
        $this->dao = new DaoImpotsWithTaxAdminDataInJsonFile(self::ROOT . "/taxadmindata.json");
        // un fichier temporaire
        $this->errorsFilename = tempnam(sys_get_temp_dir(), "errors");
    }

    // exécutée après chaque test
    #[\Override]
    protected function tearDown(): void
    {
        // suppression du fichier temporaire
        unlink($this->errorsFilename);
    }

    // les données de l'administration fiscale
    public function testTaxAdminData(): void
    {
        $taxAdminData = $this->dao->getTaxAdminData();
        // constantes de calcul
        $this->assertSame(1551.0, $taxAdminData->plafondQfDemiPart);
        $this->assertSame(21037.0, $taxAdminData->plafondRevenusCelibatairePourReduction);
        $this->assertSame(42074.0, $taxAdminData->plafondRevenusCouplePourReduction);
        $this->assertSame(3797.0, $taxAdminData->valeurReducDemiPart);
        $this->assertSame(1196.0, $taxAdminData->plafondDecoteCelibataire);
        $this->assertSame(1970.0, $taxAdminData->plafondDecoteCouple);
        $this->assertSame(1595.0, $taxAdminData->plafondImpotCelibatairePourDecote);
        $this->assertSame(2627.0, $taxAdminData->plafondImpotCouplePourDecote);
        $this->assertSame(12502.0, $taxAdminData->abattementDixPourcentMax);
        $this->assertSame(437.0, $taxAdminData->abattementDixPourcentMin);
        // tranches de l'impôt
        $this->assertSame([9964.0, 27519.0, 73779.0, 156244.0, 0.0], $taxAdminData->limites);
        $this->assertSame([0.0, 0.14, 0.30, 0.41, 0.45], $taxAdminData->coeffR);
        $this->assertSame([0.0, 1394.96, 5798.0, 13913.69, 20163.45], $taxAdminData->coeffN);
    }

    // des fichiers de données fiscales incorrects
    public static function fichiersInvalides(): array
    {
        // [nom du fichier, code d'erreur attendu]
        return [
            "fichier inexistant" => ["inexistant.json", 1],
            "JSON invalide" => ["syntaxe.json", 2],
            "clé manquante" => ["cle-manquante.json", 4],
            "valeur négative" => ["valeur-negative.json", 6],
        ];
    }

    #[DataProvider("fichiersInvalides")]
    public function testTaxAdminDataInvalides(string $fichier, int $code): void
    {
        // on attend une exception [ExceptionImpots] avec un code précis
        $this->expectException(ExceptionImpots::class);
        $this->expectExceptionCode($code);
        // construction d'une couche [dao] avec un fichier incorrect
        new DaoImpotsWithTaxAdminDataInJsonFile(__DIR__ . "/data/$fichier");
    }

    // les données des contribuables
    public function testTaxPayersData(): void
    {
        $taxPayersData = $this->dao->getTaxPayersData(self::ROOT . "/taxpayersdata.txt", $this->errorsFilename);
        // 11 contribuables corrects
        $this->assertCount(11, $taxPayersData);
        $this->assertContainsOnlyInstancesOf(TaxPayerData::class, $taxPayersData);
        // le premier et le dernier
        $this->assertEquals(new TaxPayerData("oui", 2, 55555), $taxPayersData[0]);
        $this->assertEquals(new TaxPayerData("oui", 3, 200000), $taxPayersData[10]);
        // 2 lignes erronées : les lignes 5 et 8
        $erreurs = file($this->errorsFilename, FILE_IGNORE_NEW_LINES);
        $this->assertCount(2, $erreurs);
        $this->assertStringContainsString("la ligne [5]", $erreurs[0]);
        $this->assertStringContainsString("la ligne [8]", $erreurs[1]);
    }

    // un fichier des contribuables inexistant
    public function testTaxPayersDataInexistant(): void
    {
        $this->expectException(ExceptionImpots::class);
        $this->expectExceptionCode(12);
        $this->dao->getTaxPayersData(self::ROOT . "/inexistant.txt", $this->errorsFilename);
    }
}

Commentaires

  • ligne 7 : les classes de test sont dans l'espace de noms [Application\Tests], distinct de celui de l'application ;
  • lignes 10-16 : les classes utilisées sont importées avec l'instruction [use]. Les classes de l'application sont dans l'espace de noms [Application], celles de PHPUnit dans [PHPUnit\Framework] ;
  • lignes 19-25 : on charge les fichiers de la couche [dao], comme dans [main.php]. Les chemins sont relatifs au dossier du fichier de test ([__DIR__]) : les tests fonctionnent quel que soit le dossier d'où on lance PHPUnit et quel que soit l'emplacement du dossier [php8-oct-2026] sur le disque. Le cours PHP 7 utilisait des chemins absolus [C:/Data/...], propres à la machine de l'auteur. Les classes de PHPUnit, elles, sont chargées automatiquement par le chargeur de [Composer] que PHPUnit met en place ;
  • ligne 27 : la classe de test étend [TestCase] ;
  • ligne 30 : le dossier de la version 4, où se trouvent les fichiers de données ;
  • lignes 38-44 : avant chaque test, on construit la couche [dao] comme le fait [main.php] et on crée un fichier temporaire pour les erreurs. La fonction [tempnam] crée un fichier de nom unique dans le dossier des fichiers temporaires du système, rendu par [sys_get_temp_dir]. Le cours PHP 7 faisait ce travail dans le constructeur de la classe de test : le constructeur de [TestCase] ne doit plus être redéfini depuis PHPUnit 10 ;
  • lignes 48-52 : après chaque test, le fichier temporaire est supprimé ;
  • lignes 55-73 : le test des données de l'administration fiscale. On vérifie que le contenu de l'objet [TaxAdminData] rendu par la couche [dao] correspond à ce qui est attendu. On utilise la méthode [assertSame] : [assertSame(1551.0, ...)] vérifie que la valeur est le réel 1551.0 ; [assertSame(1551, ...)] échouerait car l'entier 1551 n'est pas identique au réel 1551.0. Pour les tableaux, [assertSame] vérifie que les deux tableaux ont les mêmes clés, dans le même ordre, avec des valeurs identiques ;
  • lignes 76-85 : un fournisseur de données (data provider). C'est une méthode publique statique qui rend un tableau de jeux de données. Chaque jeu de données est un tableau des arguments à passer à une méthode de test. La clé de chaque jeu (« fichier inexistant »...) lui donne un nom qui apparaîtra dans les résultats des tests ;
  • ligne 87 : l'attribut PHP 8 [#[DataProvider]] indique que la méthode de test [testTaxAdminDataInvalides] doit être exécutée une fois pour chaque jeu de données du fournisseur [fichiersInvalides]. Les versions de PHPUnit antérieures à la version 10 utilisaient à la place une annotation dans un commentaire [/** @dataProvider fichiersInvalides */] : les annotations ne sont plus reconnues depuis PHPUnit 12 ;
  • lignes 88-95 : on vérifie qu'une couche [dao] construite avec un fichier de données fiscales incorrect lance une exception [ExceptionImpots] avec le bon code d'erreur. Les méthodes [expectException] et [expectExceptionCode] doivent être appelées avant le code qui lance l'exception : le test réussit si l'exception est lancée, il échoue sinon. Les fichiers incorrects sont dans le dossier [Tests/data] : un fichier JSON mal formé (virgule manquante), un fichier sans la clé [plafondDecoteCouple], un fichier avec une valeur négative ;
  • lignes 98-112 : le test de la lecture des données des contribuables. Le fichier [taxpayersdata.txt] de la version 4 contient 11 lignes correctes et 2 lignes erronées. On vérifie le nombre de contribuables obtenus, leur type ([assertContainsOnlyInstancesOf]), le contenu du premier et du dernier, puis le contenu du fichier des erreurs. Pour comparer deux objets [TaxPayerData], on utilise [assertEquals] : deux objets distincts ne sont jamais identiques ([===]), mais ils sont égaux ([==]) si leurs attributs sont égaux ;
  • lignes 115-120 : un fichier des contribuables inexistant doit provoquer une exception de code 12 .

11.7.4. Exécution des tests

Les tests sont exécutés dans un terminal ouvert dans le dossier [php8-oct-2026], en passant à PHPUnit le dossier des tests :

> php vendor/bin/phpunit scripts-console/impots/version-04/Tests
PHPUnit 12.5.37 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.11
Configuration: .../php8-oct-2026/phpunit.xml

..................                                                18 / 18 (100%)

Time: 00:00.020, Memory: 16.00 MB

OK (18 tests, 52 assertions)
  • ligne 5 : PHPUnit a trouvé et utilisé le fichier de configuration [phpunit.xml] ;
  • ligne 7 : un point par test réussi. Les 18 tests sont ceux des deux classes de test [DaoTest] et [MetierTest] (cette dernière est présentée au paragraphe suivant) : un test exécuté avec un fournisseur de données compte pour autant de tests qu'il y a de jeux de données ;
  • ligne 11 : le bilan : tous les tests ont réussi .

L'option [--testdox] donne un affichage plus parlant, où chaque test est décrit par une phrase construite à partir du nom de sa méthode et du nom de son jeu de données :

> php vendor/bin/phpunit --testdox scripts-console/impots/version-04/Tests
PHPUnit 12.5.37 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.11
Configuration: .../php8-oct-2026/phpunit.xml

..................                                                18 / 18 (100%)

Time: 00:00.006, Memory: 16.00 MB

Dao (Application\Tests\Dao)
 ✔ Tax admin data
 ✔ Tax admin data invalides with data set "fichier inexistant"
 ✔ Tax admin data invalides with data set "JSON invalide"
 ✔ Tax admin data invalides with data set "clé manquante"
 ✔ Tax admin data invalides with data set "valeur négative"
 ✔ Tax payers data
 ✔ Tax payers data inexistant

Metier (Application\Tests\Metier)
 ✔ Calculer impot with data set "couple, 2 enfants, 55555"
 ✔ Calculer impot with data set "couple, 2 enfants, 50000"
 ✔ Calculer impot with data set "couple, 3 enfants, 50000"
 ✔ Calculer impot with data set "célibataire, 2 enfants, 100000"
 ✔ Calculer impot with data set "célibataire, 3 enfants, 100000"
 ✔ Calculer impot with data set "couple, 3 enfants, 100000"
 ✔ Calculer impot with data set "couple, 5 enfants, 100000"
 ✔ Calculer impot with data set "célibataire, 0 enfant, 100000"
 ✔ Calculer impot with data set "couple, 2 enfants, 30000"
 ✔ Calculer impot with data set "célibataire, 0 enfant, 200000"
 ✔ Calculer impot with data set "couple, 3 enfants, 200000"

OK (18 tests, 52 assertions)

Montrons maintenant un test qui échoue. Dans la classe [MetierTest], nous introduisons une erreur dans la réduction attendue pour le 2e contribuable (346 au lieu de 347). L'exécution des tests donne alors le résultat suivant :

> php vendor/bin/phpunit scripts-console/impots/version-04/Tests
PHPUnit 12.5.37 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.5.11
Configuration: .../php8-oct-2026/phpunit.xml

........F.........                                                18 / 18 (100%)

Time: 00:00.008, Memory: 16.00 MB

There was 1 failure:

1) Application\Tests\MetierTest::testCalculerImpot@couple, 2 enfants, 50000 with data ('oui', 2, 50000, 1385, [1384, 0, 384, 346, 0.14])
Failed asserting that two arrays are identical.
--- Expected
+++ Actual
@@ @@
     'impôt' => 1384,
     'surcôte' => 0,
     'décôte' => 384,
-    'réduction' => 346,
+    'réduction' => 347,
     'taux' => 0.14,
 ]

.../php8-oct-2026/scripts-console/impots/version-04/Tests/MetierTest.php:84

FAILURES!
Tests: 18, Assertions: 52, Failures: 1.
  • ligne 7 : la lettre F (Failure) signale le test qui a échoué ;
  • ligne 13 : le test qui a échoué, avec le nom et le contenu de son jeu de données ;
  • lignes 14-24 : l'assertion qui a échoué ([assertSame]) et la différence entre les tableaux attendu (-) et obtenu (+) ;
  • ligne 26 : le fichier et la ligne de l'assertion qui a échoué ;
  • lignes 28-29 : le bilan .

Lorsqu'un test ne s'exécute même pas (par exemple parce qu'un fichier chargé par [require_once] est introuvable), PHPUnit affiche l'erreur PHP correspondante, comme le ferait l'exécution d'un script PHP classique.

11.7.5. Tests de la couche [métier]

La classe de test [MetierTest] suit les mêmes règles de construction que la classe [DaoTest] [Tests/MetierTest.php] :

<?php

// respect strict des types déclarés des paramètres et des résultats des fonctions
declare(strict_types=1);

// espace de noms des tests
namespace Application\Tests;

// les classes testées
use Application\DaoImpotsWithTaxAdminDataInJsonFile;
use Application\InterfaceMetier;
use Application\Metier;
// les classes de PHPUnit
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

// chargement des classes des couches [dao] et [métier]
require_once __DIR__ . "/../ExceptionImpots.php";
require_once __DIR__ . "/../Utilitaires.php";
require_once __DIR__ . "/../TaxAdminData.php";
require_once __DIR__ . "/../TaxPayerData.php";
require_once __DIR__ . "/../InterfaceDao.php";
require_once __DIR__ . "/../TraitDao.php";
require_once __DIR__ . "/../DaoImpotsWithTaxAdminDataInJsonFile.php";
require_once __DIR__ . "/../InterfaceMetier.php";
require_once __DIR__ . "/../Metier.php";

final class MetierTest extends TestCase
{
    // le dossier de la version 4
    private const string ROOT = __DIR__ . "/..";
    // la couche [métier] testée
    private InterfaceMetier $métier;

    // exécutée avant chaque test
    #[\Override]
    protected function setUp(): void
    {
        // création de la couche [dao]
        $dao = new DaoImpotsWithTaxAdminDataInJsonFile(self::ROOT . "/taxadmindata.json");
        // création de la couche [métier]
        $this->métier = new Metier($dao);
    }

    // les contribuables du tableau des chiffres officiels
    public static function contribuables(): array
    {
        // [marié, enfants, salaire, impôt officiel, résultats attendus de l'algorithme]
        return [
            "couple, 2 enfants, 55555" => ["oui", 2, 55555, 2815,
                ["impôt" => 2814, "surcôte" => 0, "décôte" => 0, "réduction" => 0, "taux" => 0.14]],
            "couple, 2 enfants, 50000" => ["oui", 2, 50000, 1385,
                ["impôt" => 1384, "surcôte" => 0, "décôte" => 384, "réduction" => 347, "taux" => 0.14]],
            "couple, 3 enfants, 50000" => ["oui", 3, 50000, 0,
                ["impôt" => 0, "surcôte" => 0, "décôte" => 720, "réduction" => 0, "taux" => 0.14]],
            "célibataire, 2 enfants, 100000" => ["non", 2, 100000, 19884,
                ["impôt" => 19884, "surcôte" => 4480, "décôte" => 0, "réduction" => 0, "taux" => 0.41]],
            "célibataire, 3 enfants, 100000" => ["non", 3, 100000, 16782,
                ["impôt" => 16782, "surcôte" => 7176, "décôte" => 0, "réduction" => 0, "taux" => 0.41]],
            "couple, 3 enfants, 100000" => ["oui", 3, 100000, 9200,
                ["impôt" => 9200, "surcôte" => 2180, "décôte" => 0, "réduction" => 0, "taux" => 0.3]],
            "couple, 5 enfants, 100000" => ["oui", 5, 100000, 4230,
                ["impôt" => 4230, "surcôte" => 0, "décôte" => 0, "réduction" => 0, "taux" => 0.14]],
            "célibataire, 0 enfant, 100000" => ["non", 0, 100000, 22986,
                ["impôt" => 22986, "surcôte" => 0, "décôte" => 0, "réduction" => 0, "taux" => 0.41]],
            "couple, 2 enfants, 30000" => ["oui", 2, 30000, 0,
                ["impôt" => 0, "surcôte" => 0, "décôte" => 0, "réduction" => 0, "taux" => 0.0]],
            "célibataire, 0 enfant, 200000" => ["non", 0, 200000, 64211,
                ["impôt" => 64210, "surcôte" => 7498, "décôte" => 0, "réduction" => 0, "taux" => 0.45]],
            "couple, 3 enfants, 200000" => ["oui", 3, 200000, 42843,
                ["impôt" => 42842, "surcôte" => 17283, "décôte" => 0, "réduction" => 0, "taux" => 0.41]],
        ];
    }

    // un test exécuté une fois pour chacun des contribuables du fournisseur de données
    #[DataProvider("contribuables")]
    public function testCalculerImpot(string $marié, int $enfants, int $salaire, int $impôtOfficiel, array $attendu): void
    {
        // calcul de l'impôt
        $result = $this->métier->calculerImpot($marié, $enfants, $salaire);
        // l'impôt est celui du simulateur officiel à 1 euro près
        $this->assertEqualsWithDelta($impôtOfficiel, $result["impôt"], 1);
        // les résultats sont exactement ceux attendus (mêmes clés, mêmes valeurs, mêmes types)
        $this->assertSame($attendu, $result);
    }
}

Commentaires

  • lignes 18-26 : chargement des fichiers des couches [dao] et [métier]. L'environnement du test est le même que celui de [main.php] ;
  • lignes 37-43 : instanciation des couches [dao] et [métier], comme dans [main.php] ;
  • lignes 46-73 : le fournisseur de données : les onze contribuables du tableau des chiffres officiels. Chaque jeu de données contient les données du contribuable, l'impôt donné par le simulateur officiel de l'administration fiscale [https://www3.impots.gouv.fr/simulateur/calcul_impot/2019/simplifie/index.htm] et les résultats attendus de l'algorithme du document ;
  • ligne 76 : la méthode [testCalculerImpot] sera exécutée onze fois, une fois par contribuable. Le cours PHP 7 écrivait onze méthodes de test [test1] à [test11] presque identiques : le fournisseur de données évite cette répétition ;
  • ligne 80 : le calcul de l'impôt est fait par la couche [métier] ;
  • ligne 82 : on vérifie que l'impôt calculé est celui du simulateur officiel à 1 euro près. On a vu en effet que des problèmes d'arrondi faisaient que l'algorithme du document donnait les résultats officiels à 1 euro près ;
  • ligne 84 : on vérifie que le tableau des résultats [impôt, surcôte, décôte, réduction, taux] est exactement celui attendu, y compris le type des valeurs : des entiers pour les montants, un réel pour le taux. C'est pourquoi le taux du 9e contribuable est écrit 0.0 et non 0. Cette vérification exacte protège contre toute modification involontaire de l'algorithme lors des prochaines versions .

Les résultats de l'exécution de ces tests sont ceux montrés au paragraphe précédent : les 11 tests de la couche [métier] réussissent.

11.7.6. PHPUnit dans VS Code

Le plus simple pour exécuter les tests dans [VS Code] est d'utiliser son terminal intégré (menu Terminal / New Terminal) : il s'ouvre dans le dossier [php8-oct-2026] du projet, et on y tape la commande [php vendor/bin/phpunit scripts-console/impots/version-04/Tests]. La flèche haut du clavier rappelle la dernière commande : relancer les tests après une modification du code ne coûte qu'une touche.

Quelques options utiles de la commande [phpunit] :

  • [--testdox] : affichage des tests sous forme de phrases ;
  • [--filter testCalculerImpot] : n'exécute que les tests dont le nom contient le texte indiqué ;
  • [--stop-on-failure] : s'arrête au premier échec .

Il existe aussi des extensions de [VS Code] qui intègrent PHPUnit à l'explorateur de tests de l'éditeur (par exemple l'extension « PHPUnit Test Explorer ») : elles permettent de lancer un test d'un clic et d'afficher les résultats à côté du code. Elles exécutent en réalité la même commande [phpunit] que nous tapons dans le terminal.

11.7.7. Tests des prochaines versions

Dans la suite, les tests des couches [dao] et [métier] seront identiques à ceux de la version 4. Seul changera l'environnement du test : les fichiers chargés et la façon de construire les couches [dao] et [métier]. Nous ne présenterons donc que celui-ci et les résultats des tests.