Dans la programmation objet, il arrive fréquemment qu’une classe soit dépendante d’une autre :
- Soit cette autre classe est un composant, c’est-à-dire qu’elle appartient à la première.
Généralement, cette autre classe est définie en tant que propriété de la première et affectée dans le constructeur. - Soit cette autre classe est une dépendance, c’est-à-dire qu’elle est fournie à la première dans un cas d’utilisation
précis.
Généralement, cette autre classe est définie en tant que paramètre de la méthode dans laquelle la première nécessite l’utilisation de cette classe. Elle peut aussi être injectée en tant que dépendance.
Dans un contexte de tests, cela signifie que pour tester la première classe, il faut créer les classes dont elle dépend. Sachant que ces classes peuvent, éventuellement, aussi dépendre d’autres classes, il peut arriver qu’il soit nécessaire d’instancier des dizaines de classes pour en tester une seule, ce qui n’est pas agréable pour écrire des tests.
Par ailleurs, ces autres classes peuvent requérir des relations avec des composants externes (BDD, fichiers, API) qui ne sont pas forcément accessibles dans un environnement de test, ou ces autres classes peuvent ne pas être totalement prêtes et fonctionnelles.
Au sein des tests unitaires, on n’a pas besoin de s’assurer que les classes annexes soient fonctionnelles puisqu’on ne veut tester qu’une partie de notre classe.
Comme dans le monde du showbiz, on peut utiliser des “doublures” qui portent les noms de Stubs
ou de Mocks
.
Ces composants ont pour objectif de simuler le comportement d’une classe et de ses méthodes afin de pouvoir tester unitairement sans être dépendant de l’implémentation d’autres classes ou de composants externes.
Ces composants peuvent alors simuler plusieurs choses :
- L’instanciation d’une classe, ou l’implémentation d’une interface (ou de plusieurs en même temps).
- Le retour d’une méthode.
- Et dans le cas des
Mocks
, le nombre d’appels à certaines méthodes ou encore les paramètres utilisés.
En revanche, il y a des limitations à l’utilisation des “doublures” : les classes final
ne peuvent pas avoir de
doublure et les méthodes private
, static
ou final
ne peuvent pas non plus être simulées par une doublure.
0. Modifications du code source
Votre version actuelle du code source de l’application ne vous permet pas de définir des tests de “doublures” assez d’une manière simple et accessible, c’est pourquoi vous allez récupérer une autre version de l’application.
- Assurez-vous d’avoir commité votre travail en local.
- Récupérez le commit
4409825
du repository templatehttps://github.com/3OLEN/phpunit-tp
.
Je vous laisse faire la petite recherche sur Internet pour trouver la solution. - Vérifiez que vous avez bien des fichiers dans
Entity/
ou dansRepository/
. - Vérifiez que vos tests passent toujours.
Maintenant que votre code source est à niveau, vous allez pouvoir faire du test de doublure.
1. Validation de l’entité
Les modifications récupérées concernent les prémices de l’intégration d’une base de données, qui de toute façon n’a pas d’intérêt dans des tests unitaires (il est tout à fait possible de définir des tests d’intégration codés avec PHPUnit). Néanmoins, les services et les interfaces sont définis et suffisamment prêts pour faire du test unitaire.
Un nouveau service a été développé : QuoteEntityValidator
, dont l’objectif est de valider qu’un objet de l’entité
Quote
répond à des règles de validation :
- Il est persisté et présent en base.
- Ses champs sont valorisés.
L’idée, c’est de s’assurer que l’objet que l’on manipule est bien réel et qu’il va pouvoir être utilisé notamment par la
QuoteRiddleFactory
afin de le transformer en QuoteDto
pour qu’il soit utilisé dans le reste de l’application.
Cette classe est parfaite pour utiliser les Stubs (🇫🇷 bouchon) et les Mocks (🇫🇷 simulacre).
1.1. 🤔 Réflexions sur le test de la validation
Ce service de validation met à disposition la méthode assertValidity()
afin de réaliser les diverses vérifications.
Pour que l’action soit menée à bien, le service a besoin de plusieurs éléments :
- Un objet de l’entité
Quote
, bien entendu, fourni en paramètre. - Une dépendance vers le repository de l’entité
Quote
, par le biais de son interface.
Une interface, qui d’ailleurs, n’existe pas encore, mais grâce aux “doublures” ce n’est pas un problème. - Une dépendance vers un autre service de validation, en l’occurrence pour l’entité
Media
(composite de l’entitéQuote
du fait d’un lien de clef étrangère en BDD).
Ces classes devront être simulées pour plusieurs raisons :
- Il est plus simple de simuler l’instance d’une classe au lieu de l’instancier à la main, surtout si cette classe a besoin de divers éléments ou d’une certaine logique.
- Les implémentations des diverses interfaces doivent être simulées ; leur fonctionnement effectif ou non ne doit pas
avoir d’incidence sur les tests unitaires (dans les tests d’intégration, fonctionnels ou de bout-en-bout, en
revanche, oui).
Par ailleurs, cela permet de tester la classe alors même qu’une interface ne possède pas encore d’implémentation effective. - Certains comportements ne sont possibles que par rapport à des retours spécifiques des méthodes des classes utilisées. Il est donc intéressant de pouvoir manipuler ces retours.
Vous allez avoir besoin des Stubs
.
1.2. ✅ Tester le cas usuel (valide)
Au sein d’une classe de test (vous testez la méthode assertValidity()
de la classe QuoteEntityValidator
), vous allez
devoir créer une instance de la classe, en utilisant des stubs (un petit coup d’œil à la documentation) pour
remplacer les deux paramètres $quoteRepository
et $movieEntityValidator
.
Puis, vous allez fournir le stub de l’entité Quote
à la méthode.
1.2.1. Exécuter le test
Une fois que votre appel est prêt, vous allez pouvoir exécuter le test.
Vous devriez avoir un rejet de la part du validateur par le biais d’une exception. Ce peut être pour deux raisons :
- La méthode
Quote::getId()
retourne la valeurnull
. - La méthode
QuoteRepositoryInterface::exist()
retourne la valeurfalse
.
Un stub n’exécute pas le code de ses méthodes, mais il retourne une valeur par défaut (définie par PHPUnit) si la méthode a un type de retour.
N’hésitez pas à utiliser des var_dump()
pour comprendre un peu ce qu’il se trame.
1.2.2. Définir un retour aux méthodes du stub
Vous allez devoir définir le retour des deux méthodes indiquées un peu plus haut afin de vous positionner dans un cas qui pourrait fonctionner :
$doubledQuote = $this->createStub(Quote::class);
$doubledQuote
->method('getId')
->willReturn(1);
et
$mockedQuoteRepository = $this->createStub(QuoteRepositoryInterface::class);
$mockedQuoteRepository
->method('exist')
->willReturn(true);
De cette manière, vous devriez pouvoir faire passer le premier rejet de la méthode de vérification.
L’entité
Quote
définit des mutateurs (setters), vous pourriez alors définir la valeur de la propriété$id
de votre stub sans pour autant configurer la méthodegetId()
pour retourner un résultat. Si vous testez ce code :$doubledQuote = $this->createStub(Quote::class); $doubledQuote->setId(1);
Au lieu de celui indiqué plus haut, vous verrez que la valeur de
$id
reste ànull
. La raison est écrite un peu plus haut.
1.2.3. Jusqu’au succès, ou presque…
En ajoutant d’autres configurations de retour de méthode pour vos trois stubs, vous devriez pouvoir arriver jusqu’à la réussite de votre test.
Si vous n’avez pas réalisé d’assertion dans votre test, il devrait être marqué en “risky”, parce qu’il ne réalise aucune assertion. On pourrait rajouter une assertion après l’appel à la méthode, mais pour tester quoi ?
C’est là qu’une des particularités des Mocks
entre en jeu, puisque vous allez pouvoir tester que des appels à des
méthodes de votre “doublure” (en l’occurrence de votre “simulacre”) ont été faits et mettre le nombre de fois.
Vous allez donc vous assurer que la méthode exist()
du repository a été appelée ET que la méthode assertValidity()
du service MovieEntityValidator
a été appelée également :
$mockedQuoteRepository = $this->createMock(QuoteRepositoryInterface::class);
$mockedQuoteRepository
->expects(static::once())
->method('exist')
->willReturn(true);
$mockedMovieValidator = $this->createMock(MovieEntityValidator::class);
$mockedMovieValidator
->expects(static::once())
->method('assertValidity');
Ces deux services-là vont alors devenir des Mocks
plutôt que les Stubs
que l’on avait définis plus tôt.
Bien sûr, vous gardez le stub de l’entité Quote
.
Ces instructions expect vont agir comme des assertions et si la condition indiquée n’est pas respectée, l’assertion sera considérée en erreur. Vous pouvez définir qu’une méthode ne sera jamais appelée, ou une seule fois, ou 7 fois, ou au moins 12 fois, ou moins de 3 fois, etc.
En spécifiant ces deux instructions, on veut s’assurer que pour la validation de l’entité, le service a bien fait appel à ces deux méthodes exactement une fois chacun.
En exécutant le test, vous devriez avoir deux assertions supplémentaires et tout en « OK ».
1.3. ❌ Tester les cas de rejet
Lors d’un rejet, la méthode lève une exception : InvalidLoadedEntityException
. En vous basant sur ce que vous avez
fait plus haut, vous allez vous assurer que les cinq cas suivants sont en rejet :
Quote::$id
est ànull
.QuoteRepositoryInterface::exist()
retournefalse
.Quote::$value
est ànull
.Quote::$movie
est ànull
.MovieEntityValidator::assertValidity()
lève uneInvalidLoadedEntityException
.- Oui, un
Mock
peut configurer une méthode à lever une exception. - Vous vérifiez également que l’exception levée a pour
$previous
une instance deInvalidLoadedEntityException
.
- Oui, un
Vous utiliserez un DataProvider
, ce sera plus simple et évolutif si d’autres cas doivent être testés plus tard.
Assertion des appels
Améliorer ces tests de rejet en définissant si les méthodes QuoteRepositoryInterface::exist()
et
MovieEntityValidator::assertValidity()
sont appelées ou non, selon les cas.
Étudiez bien le code de la méthode de test pour vous en assurer.
Pour boucler la boucle
Vous avez pu manipuler un peu les “doublures” (dans le cadre des tests, pas dans le domaine du cinéma attention !) afin de définir des tests unitaires plus poussés sans pour autant vous bloquer parce qu’une implémentation est manquante ou parce que vous devez instancier pléthore de classes pour simplement vérifier un petit cas particulier.
Vous allez maintenant voir pour intégrer PHPUnit au sein d’une chaîne d’intégration continue.