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.

  1. Assurez-vous d’avoir commité votre travail en local.
  2. Récupérez le commit 4409825 du repository template https://github.com/3OLEN/phpunit-tp.
    Je vous laisse faire la petite recherche sur Internet pour trouver la solution.
  3. Vérifiez que vous avez bien des fichiers dans Entity/ ou dans Repository/.
  4. 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 :

  1. Il est persisté et présent en base.
  2. 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 :

  1. La méthode Quote::getId() retourne la valeur null.
  2. La méthode QuoteRepositoryInterface::exist() retourne la valeur false.

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éthode getId() 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 :

  1. Quote::$id est à null.
  2. QuoteRepositoryInterface::exist() retourne false.
  3. Quote::$value est à null.
  4. Quote::$movie est à null.
  5. MovieEntityValidator::assertValidity() lève une InvalidLoadedEntityException.
    • 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 de InvalidLoadedEntityException.

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.