TD3 – Tests unitaires, Couche Service PHPUnit, Architecture
L’objectif de cette séance est de vous former à la mise en place de tests unitaires sur une application web PHP.
Nous allons voir que pour qu’une application soit testable efficacement, il faut que celle-ci présente une architecture réfléchie permettant de véritablement tester une partie du code (une classe) de manière indépendante. Pour cela, il faudra appliquer les différents principes SOLID que vous avez étudié cette année, notamment dans le cours de qualité de développement.
Pour illustrer tout cela, nous allons donc repartir du code de l’application The Feed obtenu à l’issue du TD2 de complément web. Vous devez donc avoir terminé ce TD avant de commencer celui-ci.
Le TD devra être obligatoirement réalisé sur PHPStorm afin de profiter des différentes fonctionnalités de couplage avec PHPUnit qu’offre cet IDE.
Note importante : Lors du TD, vous utiliserez diverses dépendances dans vos classes. Parfois, il vous sera explicitement cité la ligne d’import de cette dépendance (avec un use
). Si ce n’est pas le cas, il faudra importer vous-même la bonne classe. Dans ce cas, PHPStorm
peut vous aider ! La classe dont l’import est manquant apparaitra en surbrillance avec un fond jaune. Vous pouvez alors passer votre curseur sur le nom de la classe et cliquer sur Import class
.
Découverte de PHPUnit
PHPUnit est une librairie PHP permettant de réaliser des tests unitaires sur une application PHP. Son fonctionnement est similaire à JUnit que vous utilisez notamment en cours de Tests.
PHPUnit intègre par défaut les outils nécessaires à l’utilisation de mocks ainsi que l’analyse de la couverture de code. Nous aurons l’occasion de revenir sur ces notions au cours du TD.
Installation et configuration
Comme toute librairie PHP, PHPUnit s’installe à l’aide de composer.
-
À la racine de votre projet, exécutez la commande suivante (toujours dans votre conteneur docker) :
composer require phpunit/phpunit
S’il vous est demandé si vous préférez placer le package dans
require-dev
, vous pouvez répondreyes
. Cela permet de différencier dans lecomposer.json
les dépendances liées au fonctionnement global de l’application (celles de la sectionrequire
) et celles exclusivement liées à la phase de développement, aux tests, etc. (commephpunit
). La commandecomposer install
installe toutes les dépendances, mais si on utilise l’option--no-dev
, seules les dépendances derequire
seront installées. -
Nous allons maintenant configurer
PHPUnit
et créer les dossiers nécessaires à son fonctionnement. Commencez par créer un fichierphpunit.xml
à la racine du projet et complétez-le avec le contenu suivant :<?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" cacheDirectory=".phpunit.cache" executionOrder="depends,defects" displayDetailsOnPhpunitDeprecations="true" failOnRisky="true" failOnWarning="true"> <testsuites> <testsuite name="unit"> <directory>./tests/unit</directory> </testsuite> </testsuites> <source ignoreIndirectDeprecations="true" restrictNotices="true" restrictWarnings="true"> <include> <directory>src</directory> </include> </source> <coverage> <report> <clover outputFile="reports/coverage/coverage.xml"/> </report> </coverage> </phpunit>
Analysons un peu le contenu de ce fichier de configuration :
-
Le paramètre
bootstrap
permet d’indiquer où se trouve le fichier d’autoloading, nécessaire pour charger le bon fichier à partir de sonnamespace
. -
Le paramètre
cacheDirectory
permet de définir la localisation dossier de cache utilisé parPHPUnit
. -
La section
testsuites
nous permet de configurer plusieurs batteries de tests : tests unitaires, tests d’intégration, etc. Pour ce TD, nous n’utiliserons que des tests unitaires. On configure le dossier de test en dehors du code source du projet dans un dossiertests/units
. -
La section
source
permet d’indiquer où se situe le code source de notre projet (le code qui sera testé). Ici, on indique doncsrc
. -
Enfin, la section
coverage
permet de définir où sera généré le rapport concernant la couverture de code (dont nous reparlerons plus tard).
-
-
À la racine de votre projet, créez un dossier
tests
puis, à l’intérieur, un dossierunit
. Ensuite, afin de bénéficier d’un namespace en CamelCase (pour plus de confort) comme pour le reste du projet, modifiez le fichiercomposer.json
dans le but de spécifier le bon namespace dans la sectionautoload
:{ "autoload": { "psr-4": { "TheFeed\\": "src", "Tests\\Unit\\": "tests/unit" } }, ... }
Ensuite, exécutez la commande suivante pour mettre à jour le fichier
autoloader.php
(toujours dans votre conteneur docker) :composer dump-autoload
-
À la racine de votre projet (toujours dans votre conteneur docker), exécutez la commande suivante, qui permet d’exécuter les tests :
php -d xdebug.mode=coverage ./vendor/bin/phpunit
Il n’y a pas de résultat pour le moment, mais c’est normal, vous n’avez pas encore de tests !
-
Deux dossiers ont été générés :
reports
et.phpunit.cache
. Ces répertoires ne doivent pas être versionnés, excluez-les donc dans votre fichier.gitignore
.
Il est bien sûr possible de configurer votre IDE pour lancer les tests depuis l’interface plutôt qu’en ligne de commande, mais la conteneurisation du projet rend cela un peu plus compliqué à mettre en place. Pour ce TD, nous nous contenterons donc de lancer les tests avec une commande.
Une première classe de test
Un Test Unitaire
se traduit par une fonction dans une classe dédiée qui exécute différents tests sur des objets de l’application. Il s’agit de vérifier, par exemple, si le retour d’une fonction avec un paramétrage spécifique est bien conforme aux attentes et aux spécifications. On peut aussi tester si l’exécution d’un code déclenche des exceptions.
Les possibilités sont très riches. Pour créer une classe de test, il suffit d’étendre la classe TestCase
. À partir de
là, le développeur a accès à une grande variété de méthodes internes pour réaliser des assertions. Une assertion est simplement une vérification qui est faite (sur un résultat, sur un comportement…). Si cette vérification échoue (résultat différent de ce qui est attendu) le test échoue alors.
Parmi les méthodes d’assertion, on peut citer :
-
assertEquals(resultatAttendu, resultat, message)
: permet de vérifier l’égalité entre un résultat attendu, et un résultat (obtenu après l’exécution d’une méthode, par exemple). Le troisième paramètre est un message (optionnel) qui permet de donner plus détail en cas d’échec du test (ce message sera affiché en sortie). -
assertTrue(resultat, message)
: permet de vérifier qu’un résultat vaut true. Il existe égalementassertFalse(resultat, message)
. -
assertCount(tailleAttendue, structure, message)
: permet de vérifier la taille d’une structure de données (typiquement, un tableau). -
assertEmpty(structure, message)
: permet de vérifier qu’une structure de données est bien vide. -
assertNull(resultat, message)
: permet de vérifier qu’un résultat est bien null. Il existe aussiassertNotNull(resultat, message)
.
Cette liste est bien sûr non exhaustive et vous pourrez explorer plus en détail toutes les assertions disponibles sur la documentation officielle.
Une autre méthode bien pratique est aussi expectException(exceptionClass)
. Cette méthode est à utiliser avant
d’exécuter un bout de code et permet de vérifier que l’exception précisée a bien été levée. On peut aussi utiliser expectExceptionMessage(message)
pour vérifier le message de l’exception levée.
Enfin, dans chaque classe de test, il est possible de redéfinir quatre méthodes bien utiles :
-
setUp
: cette méthode est exécutée avant chaque méthode de test. Elle permet, par exemple, de configurer certaines variables afin de les rendre vierges avant d’exécuter chaque test. -
tearDown
: cette méthode est exécutée après chaque méthode de test. Elle doit permettre de nettoyer les effets de bord occasionnés par chaque test (par exemple : nettoyer la base de données de tests).
Il existe également deux versions statiques de ces méthodes : setUpBeforeClass
et tearDownAfterClass
qui sont exécutées respectivement avant l’exécution du premier test et après l’exécution du dernier test (donc, une seule fois).
Prenons l’exemple de la classe suivante :
namespace TheFeed\Lib;
use Exception;
class Ensemble {
private array $tableauEnsemble;
public function __construct() {
$this->tableauEnsemble = [];
}
public function contient($valeur) {
return in_array($valeur, $this->tableauEnsemble);
}
public function ajouter($valeur) {
if(!$this->contient($valeur)) {
$this->tableauEnsemble[] = $valeur;
}
}
public function getTaille() {
return count($this->tableauEnsemble);
}
public function estVide() {
return $this->getTaille() == 0;
}
public function pop() {
if($this->estVide()) {
throw new Exception("L'ensemble est vide!");
}
return array_pop($this->tableauEnsemble);
}
}
On pourrait alors écrire la classe de test suivante :
namespace Tests\Unit;
use Exception;
use PHPUnit\Framework\TestCase;
use TheFeed\Lib\Ensemble;
class EnsembleTest extends TestCase {
private $ensembleTeste;
//On réinitialise l'ensemble avant chaque test
protected function setUp(): void
{
parent::setUp();
$this->ensembleTeste = new Ensemble();
}
public function testVideDepart() {
$this->assertEquals(0, $this->ensembleTeste->getTaille());
}
public function testAjout() {
$this->assertFalse($this->ensembleTeste->contient(7));
$this->ensembleTeste->ajouter(7);
$this->assertTrue($this->ensembleTeste->contient(7));
$this->assertEquals(1, $this->ensembleTeste->getTaille());
//On n'ajoute pas deux fois dans un ensemble, donc la taille doit rester à 1
$this->ensembleTeste->ajouter(7);
$this->assertEquals(1, $this->ensembleTeste->getTaille());
}
public function testPop() {
$this->ensembleTeste->ajouter(1);
$this->ensembleTeste->ajouter(2);
$this->ensembleTeste->ajouter(3);
$this->assertEquals(3, $this->ensembleTeste->pop());
$this->assertEquals(2, $this->ensembleTeste->pop());
$this->assertEquals(1, $this->ensembleTeste->pop());
$this->expectException(Exception::class);
$this->expectExceptionMessage("L'ensemble est vide!");
$this->ensembleTeste->pop();
}
}
-
Dans le dossier
src/Lib
, créez la classeEnsemble
puis, danstest\unit
la classeEnsembleTest
en copiant le code donné ci-dessus. -
Lancez les tests unitaires (avec la commande donnée précédemment) et observez les résultats.
-
Glissez une erreur dans le code de la classe
Ensemble
et relancez les tests. Observez la sortie. Remettez tout en ordre (enlevez le bug).
Attention ! Le nom de toutes vos classes de tests doit se terminer par Test
! (Sinon la classe ne sera pas prise en compte lors de l’exécution de tests). Aussi, chaque nom de méthode de test doit soit débuter par test
soit posséder l’attribut #[Test]
:
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
//Le nom termine par "Test"
class MonTest extends TestCase {
//Sera exécuté, car est préfixé par "test"
public function testCoucou() {
}
//Ne sera pas exécuté
public function coucou1() {
}
//Sera exécuté, car possède l'attribut #[Test]
#[Test]
public function coucou2() {
}
}
La couche Service
Nous avons réalisé des premiers tests simples afin de comprendre le fonctionnement de PHPUnit. Maintenant, nous allons mettre en œuvre cet outil de manière plus concrète en testant notre application web. Néanmoins, vous allez constater un problème majeur : l’application n’est pas testable en l’état.
En effet, pour tester, nous avons besoin de faire des assertions sur des résultats (ou des comportements) spécifiques obtenus lors de l’exécution d’une fonctionnalité. Actuellement, les fonctionnalités sont réalisées par les contrôleurs.
Or, les différentes fonctions des contrôleurs renvoient un objet Response
qui n’est pas bien exploitable. Cet objet contient le code complet de la page HTML
renvoyée au client, ce qui n’est donc pas (ou difficilement) testable en l’état. Ce problème est lié au fait que les contrôleurs ont beaucoup trop de responsabilités et ne répartissent pas le travail. De l’extérieur, ils agissent comme une boîte noire et il est alors difficile de récupérer des données intéressantes pour les tests. Il semble aussi difficile de fournir des données aux contrôleurs car ceux-ci se servent directement des données de la requête HTTP.
Une application web comme tout logiciel peut être organisé selon une architecture qui sépare de manière optimisée les classes du programme en couches selon leur rôle.
Dans un logiciel, on peut trouver différents types de couche. Par exemple (sans être exhaustif) :
-
La couche présentation qui permet de gérer les différentes parties graphiques et surtout l’interaction avec l’utilisateur. Pour une application web, cela va correspondre à la partie contenant les vues, c’est-à-dire les fichiers responsables de générer le code HTML (et également les ressources JavaScript, CSS, etc.)
-
La couche métier qui contient le cœur de l’application, à savoir les différentes entités manipulées (essentiellement, les classes dans
DataObject
) ainsi que des classes de services qui permettent de manipuler ces entités et d’implémenter la partie logique de votre application. -
La couche application qui permet de faire le lien entre la couche présentation et la couche métier. Elle contient les différents contrôleurs dont le rôle est de gérer les évènements de l’interface, d’interagir avec la couche métier et de transmettre les résultats obtenus à l’IHM. Dans une application web, les événements sont les requêtes reçues par l’application web (et ses paramètres, via l’URL). Une requête est décomposée puis la bonne méthode du contrôleur est exécutée avec les paramètres correspondants.
-
La couche de persistance (stockage) qui permet de gérer la persistance des données à travers une forme de stockage configurée (base de données, fichier…). Son rôle va donc être de sauvegarder et charger les données des différentes entités de la couche métier. C’est cette couche qui va contenir les différents repositories.
-
Parfois, une couche réseau dans le cadre d’une application client/serveur. Cette couche va gérer la transmission des données entre deux programmes (avec des sockets, etc.). Dans une application web, il n’y a pas besoin de gérer explicitement cette couche qui est prise en charge par le protocole HTTP ou HTTPS.
Bref, ces différentes couches permettent de définir et de séparer les zones d’activités du logiciel. Tout l’intérêt est de faire communiquer ces couches entre elles.
La connaissance de ces couches ne donne pas encore la structure de l’application : il faut choisir une architecture qui permet de les structurer, de les exploiter et de définir comment elles communiquent concrètement. Il en existe plusieurs, et pour une application web (comme celle manipulée dans ce TP) on peut choisir l’architecture MVC
que vous connaissez déjà.
Cette architecture permet de séparer les entités, les vues et les contrôleurs de l’application et de les faire communiquer :
-
La partie modèle (M) stocke les différentes entités (nos
DataObject
) que l’on retrouve dans la couche métier ainsi que des classes liées à la couche stockage (les classes typeRepository
). -
Les différentes vues (V) correspondent à la couche présentation.
-
Les différents contrôleurs (C) gèrent la couche application et une partie de la couche (logique) métier (ils reçoivent les requêtes, vérifient les données, effectuent les opérations, etc.)
Néanmoins, il n’est pas explicitement fait mention des services dans cette architecture. En fait, dans une architecture MVC
classique, le contrôleur a le rôle des services et effectue une grande partie (voir la totalité) partie de la logique métier. Néanmoins, cela peut vite créer des contrôleurs énormes ayant beaucoup trop de responsabilités. C’est pourquoi il est possible de venir placer une couche service entre les contrôleurs, les entités et la couche stockage. Ainsi, le contrôleur n’effectue pas de logique métier et on a une séparation plus forte.
Ici, la couche métier est séparée entre la partie modèle (nos entités) et les services qui manipulent ces entités. Ainsi, les différents contrôleurs n’interagissent pas directement avec les entités, mais plutôt avec des services. On pourrait alors qualifier les services de couche de validation voir de couche logique (car elle effectue d’autres opérations en plus de la validation des données).
Les interactions se dérouleraient alors dans ce sens : Vue ↔ Contrôleur ↔ Services ↔ Modèle (entités, repositories) au lieu du traditionnel Vue ↔ Contrôleur ↔ Modèle.
Dans ce cas, on étend l’architecture classique MVC
et on pourrait alors parler de MVCS
où le S
désignerait les services. Il n’y a pas de règles précise quant à l’utilisation de telle ou telle architecture, mais dans le cas de notre application, nous allons plutôt tendre vers une architecture utilisant les services. Créer une telle séparation permettra alors de pouvoir tester la logique métier indépendamment au travers des tests unitaires sur les services plutôt que sur les contrôleurs. D’une part, il sera alors possible de passer des données à ces services autrement que par une requête HTTP, et d’autre part, on pourra également obtenir un résultat exploitable et pas une page web complète.
Un service pour gérer les publications
Nous allons commencer à extraire la logique métier de notre application en créant un service pour gérer les différentes publications. Au-delà d’alléger le contrôleur des publications du code métier, nous allons aussi pouvoir considérablement réduire la partie dédiée à la gestion des erreurs !
-
Créez un dossier
Service
danssrc
. -
Dans ce nouveau dossier, créez une classe
PublicationService
. - Créez une méthode
public function recupererPublications(): array
qui permet de récupérer toutes les publications depuis le repository correspondant et de les renvoyer. Vous pouvez directement copier le code correspondant depuis la méthode
afficherListe
deControleurPublication
. -
Modifiez le code de la méthode
afficherListe
deControleurPublication
pour utiliser votre nouveau service au lieu de faire appel au repository. - Vérifiez que votre site fonctionne toujours bien.
Bien, vous avez créé votre premier service ! Mais l’intérêt d’avoir séparé ce petit bout de code n’apparait pas encore clairement. Nous allons donc pousser les choses un peu plus loin lors de la prochaine étape.
Nous allons nous intéresser à la création des publications. Actuellement, dès qu’il détecte une erreur dans la formation du message, le contrôleur ajoute un message flash d’erreur et redirige l’utilisateur. Ces vérifications font partie de la logique métier et peuvent être gérées à l’aide d’exceptions. La logique à appliquer serait plutôt la suivante :
- Le contrôleur récupère les valeurs des paramètres depuis la requête et les passe au service.
- Le service a pour but de réaliser une action (et éventuellement d’envoyer un résultat). S’il y a un problème (notamment par rapport aux paramètres), il lève une exception.
- Le contrôleur attrape les éventuelles exceptions et redirige l’utilisateur en conséquence.
-
Dans le dossier
Service
, créez un sous-dossierException
puis à l’intérieur de ce nouveau répertoire, une classeServiceException
:<?php namespace TheFeed\Service\Exception; use Exception; class ServiceException extends Exception { }
-
Dans
PublicationService
, créez une méthodecreerPublication
qui prend en paramètre un idUtilisateur et un message. La méthode doit déplacer en grande partie le code de la méthodecreerDepuisFormulaire
deControleurPublication
:public function creerPublication($idUtilisateur, $message) { $utilisateur = (new UtilisateurRepository())->recupererParClePrimaire($idUtilisateur); if ($utilisateur == null) { MessageFlash::ajouter("error", "Il faut être connecté pour publier un feed"); return ControleurPublication::rediriger('connecter'); } if ($message == null || $message == "") { MessageFlash::ajouter("error", "Le message ne peut pas être vide!"); return ControleurPublication::rediriger('afficherListe'); } if (strlen($message) > 250) { MessageFlash::ajouter("error", "Le message ne peut pas dépasser 250 caractères!"); return ControleurPublication::rediriger('afficherListe'); } $publication = Publication::construire($message, $utilisateur); (new PublicationRepository())->ajouter($publication); }
Note : Si vous avez une erreur de l’IDE
rediriger
has protected visibility, ce n’est pas grave, elle sera réglée avec la prochaine question.Vous aurez aussi des erreurs liées au type de retour de la méthode, mais n’y prêtez pas attention pour l’instant.
-
Dans la nouvelle méthode
creerPublication
, remplacez toutes les lignes qui ajoutent un message flash et redirigent l’utilisateur par le déclenchement d’une ServiceException contenant le message flash initialement prévu comme message flash. La syntaxe est la suivante :throw new ServiceException("Mon message d'erreur!");
-
Modifiez la méthode
creerDepuisFormulaire
deControleurPublication
afin d’utiliser le service de publications et de gérer l’exception. Dans le cas où une ServiceException est interceptée, vous devez ajouter le message de l’exception comme message flash puis rediriger l’utilisateur vers la routeafficherListe
. Globalement, cela doit ressembler à quelque chose comme ça :public static function creerDepuisFormulaire() : Response { $idUtilisateurConnecte = ConnexionUtilisateur::getIdUtilisateurConnecte(); $message = $_POST['message']; try { //Utilisation du service } catch(ServiceException $e) { //Ajout du message flash } return ControleurPublication::rediriger('afficherListe'); }
Aide : Allez voir si nécessaire la documentation de la classe
Exception
. -
Comme d’habitude, vérifiez votre application pour vous assurer que rien n’a été cassé.
Ici, la séparation entre la couche service et application est bien visible ! Le contrôleur récupère les éléments nécessaires depuis la requête et le service, lui n’interagit pas directement avec les données de la requête (pas d’accès à $_POST
) et ne s’intéresse pas aux notions liées à la couche présentation (pas de redirection, pas de sélection de vue, pas de messages flash…). Il agit comme un module quasi indépendant des autres couches.
Un service pour gérer les utilisateurs
Nous allons continuer dans notre lancée et extraire la partie métier du contrôleur gérant les fonctionnalités liées aux utilisateurs.
Pour les fonctions qui permettent d’afficher la page de connexion ou d’inscription, il n’y a pas besoin de créer une fonctionnalité sur un service car il s’agit juste d’un affichage de page simple.
Débutons avec la création d’un nouvel utilisateur.
-
Créez une classe
UtilisateurService
dans le dossierService
. -
Ajoutez une méthode
creerUtilisateur
qui prend en paramètre un login, un mot de passe, une adresse mail et enfin un tableau de données de l’image de profil. Cette méthode reprendra en grande partie le code decreerDepuisFormulaire
du contrôleurControleurUtilisateur
.Comme d’habitude, il ne faudra pas faire appels aux variables liées à la requête dans cette méthode (
$_POST
,$_FILES
, etc.). Ces données vous sont fournies par le contrôleur et peuvent êtrenull
. Il faudra d’ailleurs penser à vérifier si ces valeurs sont nulles ou non. La méthode ne doit rien retourner (simplement créer l’utilisateur) et lever desServiceException
si différentes contraintes sont violées (taille du login, mot de passe, format de l’adresse mail, etc.). Le paramètre$donneesPhotoDeProfil
correspond au tableau obtenu par lecture de$_FILES["..."]
.public function creerUtilisateur($login, $motDePasse, $email, $donneesPhotoDeProfil) : void { //TO-DO //Verifier que les attributs ne sont pas null //Verifier la taille du login //Verifier la validité du mot de passe //Verifier le format de l'adresse mail //Verifier que l'utilisateur n'existe pas déjà //Verifier que l'adresse mail n'est pas prise //Verifier extension photo de profil //Enregistrer la photo de profil //Chiffrer le mot de passe //Enregistrer l'utilisateur... }
-
Adaptez la méthode
creerDepuisFormulaire
deControleurUtilisateur
pour utiliser votre nouveau service. Attention, il ne faut plus vérifier ici le fait qu’une donnée est nulle ou non (on doit pouvoir passer une donnée nulle au service). En remplacement, vous pouvez utiliser l’expression suivante :// Si $_POST["donnee"] n'existe pas, $donnee prend la valeur null. $donnee = $_POST["donnee"] ?? null;
Le nouveau code aura donc cette allure :
public static function creerDepuisFormulaire(): Response { //Recupérer les différentes variables (login, mot de passe, adresse mail, données photo de profil...) try { //Enregistrer l'utilisateur via le service } catch(ServiceException $e) { //Ajouter message flash d'erreur //Rediriger sur le formulaire de création } //Ajouter un message flash de succès (L'utilisateur a bien été créé !) //Rediriger sur la page d'accueil (route afficherListe) }
-
Comme toujours, vérifiez l’état de votre application.
Maintenant, passons au cas de la fonctionnalité permettant d’afficher une page personnelle.
La méthode afficherPublications
effectue deux actions : récupération de l’utilisateur concerné d’une part (pour afficher son login) et, d’autre part, récupération des publications de l’utilisateur. Il va donc y avoir deux actions à effectuer, dans deux services différents.
-
Dans la classe
UtilisateurService
, créez une méthoderecupererUtilisateurParId
qui prend en paramètre un identifiant d’utilisateur et un booléenautoriserNull
. Ce booléen a pour but de préciser si une exception doit être levée ou non si l’utilisateur sélectionné n’existe pas (dans certains cas, on veut simplement récupérer la valeurnull
sans lever d’exceptions). La méthode doit donc renvoyer à terme l’utilisateur ciblé par l’identifiant (en se servant du repository). SiautoriserNull
vautfalse
et que l’utilisateur récupéré estnull
, il faut lever uneServiceException
(l’utilisateur n’existe pas !).public function recupererUtilisateurParId($idUtilisateur, $autoriserNull = true) : ?Utilisateur { $utilisateur = ... if(!$autoriserNull && ...) { ... } return $utilisateur; }
-
La partie qui a pour but de récupérer des publications doit plutôt être codée au niveau de la classe
PublicationService
. Ajoutez donc une méthoderecupererPublicationsUtilisateur($idUtilisateur)
à ce service en reprenant la partie du code deafficherPublications
qui récupère les publications. -
Remplacez le code de
afficherPublications
afin d’utiliser les deux méthodes (recupererUtilisateurParId
etrecupererPublicationsUtilisateur
deUtilisateurService
etPublicationService
). Il ne faudra pas autoriser le fait de récupérer un utilisateurnull
. Veillez à bien traiter une éventuelleServiceException
. -
Ajoutez une vue
publication/page_perso.html.twig
qui étend le templatepublication/feed.html.twig
et modifie simplement{% block page_title %}
pour que le titre de la page devienne soitPage perso de login_de_l_utilisateur
. ModifiezafficherPublications
pour utiliser cette nouvelle vue (et lui passer les bonnes informations). -
Vérifiez que tout fonctionne bien.
Si tout marche bien, vous commencez à maîtriser le processus ! Terminons donc le travail avec ce contrôleur avant de passer à la seconde phase de tests.
-
En vous inspirant du travail réalisé lors des questions précédentes, adaptez la méthode
connecter
afin de faire migrer une partie de la logique du code dans une méthode adaptée dans la classeUtilisateurService
. -
Faites de même pour la méthode
deconnecter
. -
Vérifiez le fonctionnement de l’application.
Premiers tests sur l’application
Maintenant que la partie métier de notre application est (partiellement) extraite, nous allons pouvoir faire nos premiers tests.
-
Créez une classe
PublicationServiceTest
dans le répertoiretests\unit
. -
Ajoutez un attribut
service
qui sera ré-instancié par unPublicationService
avant chaque test (via lesetUp
). -
Créez un test
testCreerPublicationUtilisateurInexistant
qui teste de créer une publication en précisant un identifiant d’utilisateur qui n’est pas enregistré dans la base (par exemple,-1
). Votre test doit vérifier qu’uneServiceException
est bien levée et que le message d’erreur correspond bien à celui attendu. -
Créez un test
testCreerPublicationVide
qui teste de créer une publication sans aucun contenu. Attention, ici, il faut préciser un identifiant d’utilisateur valide (qui est enregistré dans la base). Comme à la question précédente, votre test doit vérifier qu’uneServiceException
est bien levée et que le message d’erreur correspond bien à celui attendu. -
Créez un test
testCreerPublicationTropGrande
qui teste de créer une publication avec un contenu dépassant 250 caractères. Pour vous faciliter la tâche, vous pouvez utiliser la fonctionstr_repeat(chaine, nb)
qui permet d’obtenir une chaîne de caractères correspondant ànb
répétitions de la chaîne de caractèreschaine
. Mêmes vérifications à faire que précédemment. -
Créez un test
testNombrePublications
qui teste la récupération toutes les publications (via le service) et vérifie le nombre de publications récupérées. Il faudra donc compter combien de publications il y a dans votre base au préalable. -
Créez un test
testNombrePublicationsUtilisateur
qui teste la récupération de toutes les publications d’un utilisateur. Il faudra préciser un identifiant d’utilisateur existant et vérifier que le compte est bon. -
Enfin, créez un test
testNombrePublicationsUtilisateurInexistant
qui teste la récupération de toutes les publications d’un utilisateur inexistant (par exemple,-1
). Le compte des publications doit être de 0 dans ce cas. -
Si ce n’est pas déjà fait, lancez les tests unitaires et vérifiez que tous les tests passent !
Relisez les tests que vous venez d’écrire. Ne remarquez-vous pas quelques éléments étranges et même dérangeants ? Pensez sur le long terme. Nous reviendrons sur tout cela assez vite et nous n’écrirons pas de tests sur le service des utilisateurs pour le moment.
Couverture de code et portée des tests
Il est temps pour vous de découvrir un outil fort utile pour pouvoir mesurer (en partie) la qualité de vos tests : la couverture de code. Cet outil permet de réaliser des statistiques sur les portions de code que vos tests permettent de tester. Après l’exécution des tests, on peut alors visualiser le pourcentage de code testé sur une classe et on peut même aller dans le détail en visualisant les lignes de code qui ont été franchies par les tests et celles qui n’ont jamais été franchies.
Il est difficile de savoir jusqu’où tester une application. Le but des tests n’est en réalité pas de vérifier que tout fonctionne, mais plutôt de trouver des dysfonctionnements. Le nombre et la variété des tests à produire dépendent donc fortement du contexte. Néanmoins, une couverture de code de 100% (donc, des tests qui passent au moins une fois par chaque ligne de code du programme) est un premier indicateur de la qualité des tests. Dans ce cas, on peut alors considérer qu’il y a un nombre assez important de tests et qu’ils sont assez variés. Néanmoins, cela ne signifie pas nécessairement qu’il faut s’arrêter de tester à partir de là. Il faut prévoir le plus de scénarios possibles (deux scénarios différents peuvent déclencher les mêmes lignes de code).
Il faut également se poser la question de la portée des tests. Doit-on (peut-on ?) tout tester ? Par exemple, est-il pertinent d’écrire des tests unitaires pour les contrôleurs dans leur état actuel vu que leur rôle se limite à la réalisation d’un pont entre la couche présentation (les vues, la requête HTTP) et la couche service ? Cela relève plutôt de tests réalisés directement sur l’interface (ce que vous faisiez jusqu’ici). Il est possible de mettre en place des tests unitaires sur à peu près tous les éléments du programme, mais généralement, on va plutôt se concentrer sur la partie métier avec les services puis la partie modèle. Obtenir une couverture proche de 100% sur ces parties constitue un premier critère de qualité.
-
Après chaque lancement des tests unitaires, un fichier de couverture de code est généré par PHPUnit. Il s’agit du fichier
reports/coverage.xml
. En l’état, ce fichier est assez illisible, mais PHPStorm va nous permettre d’en analyser les données facilement. Si vous n’utilisez pas PHPStorm, rendez-vous au point 6 pour une solution alternative. -
Sur PHPStorm, ouvrez le menu de couverture de code en cliquant sur
View
→Tool Windows
→Coverage
. Un panneau s’ouvre à droite (il peut être fermé et rouvert grâce à l’icône de bouclier). À l’intérieur de ce menu, cliquez sur Import a report collected in CI from disk. Choisissez ensuite le fichierreports/coverage.xml
. -
PHPStorm fait un rapport vis-à-vis du contenu du fichier. Explorez son contenu. Il est notamment indiqué les fichiers qui ont été sollicités par les tests, le pourcentage de lignes de codes couvertes, etc.
-
Parcourez les différents fichiers de l’application (notamment
PublicationService
) et observez les lignes de code. Au niveau des numéros de lignes, une section verte indique que la ligne a été parcourue (et bien sûr, une section rouge indique l’inverse). -
Après exécution des tests, il faut réimporter le fichier
coverage.xml
afin de mettre à jour l’analyse menée par PHPStorm. pour cela, vous pouvez utiliser le troisième bouton situé en haut du panneau d’analyse de la couverture de code. -
Si vous n’utilisez pas PHPStorm, il est sans doute possible de faire quelque-chose de similaire avec votre IDE. Il est aussi possible de générer un rapport en HTML (pour afficher un mini-site) en ajoutant la section suivante dans la partie
coverage
dephpunit.xml
:<coverage> <report> ... <html outputDirectory="reports"/> </report> </coverage>
Après exécution des tests, le site web sera généré dans
reports
et il est accessible en ouvrant le fichierindex.html
avec un navigateur.
Maintenant, prenez l’habitude de vérifier la couverture de code de vos tests !