Tests purs en PHP et PHPUnit


Il existe de nombreux outils dans l'écosystème PHP qui fournissent des tests PHP pratiques. L'un des plus célèbres est PHPUnit , qui est presque synonyme de test dans ce langage. Cependant, peu de choses sont écrites sur les bonnes méthodes de test. Il existe de nombreuses options pour savoir pourquoi et quand écrire des tests, quel type de tests, etc. Mais pour être honnête, cela n'a pas de sens d'écrire un test si vous ne pouvez pas le lire plus tard .

Les tests sont un type particulier de documentation. Comme je l'ai écrit plus tôt sur TDD en PHP , le test indiquera toujours (ou du moins devrait) clairement quelle est la tâche d'un morceau de code particulier.

Si un test ne peut pas exprimer cette idée, alors le test est mauvais.

J'ai préparé un ensemble de techniques qui aideront les développeurs PHP à écrire de bons tests, lisibles et utiles.

Commençons par les bases


Il existe un ensemble de techniques standard que beaucoup suivent sans poser de questions. Je vais en mentionner beaucoup et essayer d'expliquer pourquoi elles sont nécessaires.

1. Les tests ne doivent pas contenir d'opérations d'entrée-sortie


La raison principale : les opérations d'E / S sont lentes et peu fiables.

Lent : même si vous disposez du meilleur matériel au monde, les E / S seront toujours plus lentes que les accès à la mémoire. Les tests doivent toujours fonctionner rapidement, sinon les gens les exécuteront trop rarement.

Peu fiable : certains fichiers, fichiers binaires, sockets, dossiers et enregistrements DNS peuvent ne pas être disponibles sur certaines machines sur lesquelles vous testez. Plus vous comptez sur les tests d'E / S, plus vos tests sont liés à l'infrastructure.

Quelles opérations concernent les E / S:

  • Lecture et écriture de fichiers.
  • Appels réseau.
  • Appels à des processus externes (à l'aide de exec , proc_open , etc.).

Il existe des situations où la présence d'opérations d'entrée-sortie vous permet d'écrire des tests plus rapidement. Mais attention: vérifiez que ces opérations fonctionnent de la même manière sur vos machines pour le développement, l'assemblage et le déploiement, sinon vous pourriez avoir de graves problèmes.

Isolez les tests afin qu'ils n'aient pas besoin d'opérations d'E / S: J'ai donné ci-dessous une solution architecturale qui empêche les tests d'effectuer des E / S en partageant la responsabilité entre les interfaces.

Un exemple:

 public function getPeople(): array { $rawPeople = file_get_contents( 'people.json' ) ?? '[]'; return json_decode( $rawPeople, true ); } 

Lorsque vous commencez à tester à l'aide de cette méthode, un fichier local sera créé et de temps en temps des instantanés seront créés:

 public function testGetPeopleReturnsPeopleList(): void { $people = $this->peopleService ->getPeople(); // assert it contains people } 

Pour ce faire, nous devons configurer les conditions préalables à l'exécution des tests. À première vue, tout semble raisonnable, mais en fait c'est terrible.

Sauter un test car les conditions préalables ne sont pas remplies ne garantit pas la qualité de nos logiciels. Cela ne fera que masquer les bugs!

Nous corrigeons la situation : nous isolons les opérations d'E / S en transférant la responsabilité à l'interface.

 // extract the fetching // logic to a specialized // interface interface PeopleProvider { public function getPeople(): array; } // create a concrete implementation class JsonFilePeopleProvider implements PeopleProvider { private const PEOPLE_JSON = 'people.json'; public function getPeople(): array { $rawPeople = file_get_contents( self::PEOPLE_JSON ) ?? '[]'; return json_decode( $rawPeople, true ); } } class PeopleService { // inject via __construct() private PeopleProvider $peopleProvider; public function getPeople(): array { return $this->peopleProvider ->getPeople(); } } 

Maintenant, je sais que JsonFilePeopleProvider utilisera les E / S dans tous les cas.

Au lieu de file_get_contents() vous pouvez utiliser une couche d'abstraction comme le système de fichiers Flysystem , pour laquelle il est facile de créer des stubs.

Et alors pourquoi avons-nous besoin de PeopleService ? Bonne question. Pour cela, des tests sont nécessaires: pour défier l'architecture et supprimer le code inutile.

2. Les tests doivent être conscients et significatifs.


La raison principale : les tests sont une forme de documentation. Gardez-les clairs, concis et lisibles.

Clarté et concision : pas de gâchis, pas de milliers de lignes de talons, pas de séquences d'instructions.

Lisibilité : les tests doivent raconter une histoire. La structure «donné, quand, alors» est excellente pour cela.

Caractéristiques d'un bon test lisible:

  • Contient uniquement les appels nécessaires à la méthode assert (de préférence un).
  • Il explique très clairement ce qui devrait se produire dans des conditions données.
  • Il teste une seule branche de l'exécution de la méthode.
  • Il ne fait pas un bout pour tout l'univers pour le bien de toute déclaration.

Il est important de noter que si votre implémentation contient des expressions conditionnelles, des opérateurs de transition ou des boucles, elles doivent toutes être explicitement couvertes par les tests. Par exemple, pour que les premières réponses contiennent toujours un test.

Je le répète: ce n’est pas une question de couverture, mais de documentation.

Voici un exemple de test déroutant:

 public function testCanFly(): void { $noWings = new Person(0); $this->assertEquals( false, $noWings->canFly() ); $singleWing = new Person(1); $this->assertTrue( !$singleWing->canFly() ); $twoWings = new Person(2); $this->assertTrue( $twoWings->canFly() ); } 

Adaptons le format «donné quand, alors» et voyons ce qui se passe:

 public function testCanFly(): void { // Given $person = $this->givenAPersonHasNoWings(); // Then $this->assertEquals( false, $person->canFly() ); // Further cases... } private function givenAPersonHasNoWings(): Person { return new Person(0); } 

Comme dans la section «Donné», «quand» et «alors» peuvent être transférés vers des méthodes privées. Cela rendra votre test plus lisible.

assertEquals gâchis inutile. La personne qui lit ceci doit retracer l'énoncé afin de comprendre ce qu'il signifie.

L'utilisation d'instructions spécifiques rendra votre test beaucoup plus lisible. assertTrue() devrait recevoir une variable booléenne, pas une expression comme canFly() !== true .

Dans l'exemple précédent, nous remplaçons assertEquals entre false et $person->canFly() par un simple assertFalse :

 // ... $person = $this->givenAPersonHasNoWings(); $this->assertFalse( $person->canFly() ); // Further cases... 

Maintenant, tout est très clair! Si une personne n'a pas d'ailes, elle ne doit pas pouvoir voler! Lire comme un poème

Maintenant, la section «Autres cas», qui apparaît deux fois dans notre texte, indique clairement que le test fait trop de déclarations. La méthode testCanFly() est complètement inutile.

Améliorons à nouveau le test:

 public function testCanFlyIsFalsyWhenPersonHasNoWings(): void { $person = $this->givenAPersonHasNoWings(); $this->assertFalse( $person->canFly() ); } public function testCanFlyIsTruthyWhenPersonHasTwoWings(): void { $person = $this->givenAPersonHasTwoWings(); $this->assertTrue( $person->canFly() ); } // ... 

Nous pouvons même renommer la méthode de test afin qu'elle corresponde au scénario réel, par exemple, dans testPersonCantFlyWithoutWings , mais tout me convient testPersonCantFlyWithoutWings .

3. Le test ne doit pas dépendre d'autres tests


La raison principale : les tests doivent s'exécuter et s'exécuter avec succès dans n'importe quel ordre.

Je ne vois pas de raisons suffisantes pour créer des interconnexions entre les tests. Récemment, on m'a demandé de faire un test de fonction de connexion, je vais le donner ici comme bon exemple.

Le test doit:

  • Générez un jeton JWT pour vous connecter.
  • Exécutez la fonction de connexion.
  • Approuvez le changement de statut.

C'était comme ça:

 public function testGenerateJWTToken(): void { // ... $token $this->token = $token; } // @depends testGenerateJWTToken public function testExecuteAnAmazingFeature(): void { // Execute using $this->token } // @depends testExecuteAnAmazingFeature public function testStateIsBlah(): void { // Poll for state changes on // Logged-in interface } 

C'est mauvais pour plusieurs raisons:

  • PHPUnit ne peut garantir cet ordre d'exécution.
  • Les tests doivent pouvoir s'exécuter indépendamment.
  • Les tests parallèles peuvent échouer de manière aléatoire.

Le moyen le plus simple de contourner ce problème est d'utiliser le schéma donné, quand, puis. Ainsi, les tests seront plus réfléchis, ils raconteront une histoire, démontrant clairement leurs dépendances, expliquant la fonction testée.

 public function testAmazingFeatureChangesState(): void { // Given $token = $this->givenImAuthenticated(); // When $this->whenIExecuteMyAmazingFeature( $token ); $newState = $this->pollStateFromInterface( $token ); // Then $this->assertEquals( 'my-state', $newState ); } 

Nous devons également ajouter des tests d'authentification, etc. Cette structure est si bonne que Behat est utilisé par défaut .

4. Toujours implémenter les dépendances


La raison principale : un très mauvais ton - pour créer un talon pour l'état global. L'incapacité à créer des stubs pour les dépendances ne permet pas de tester la fonction.

Conseil utile: Oubliez les classes statiques statiques et les instances singleton . Si votre classe dépend de quelque chose, faites en sorte qu'elle puisse être implémentée.

Voici un triste exemple:

 class FeatureToggle { public function isActive( Id $feature ): bool { $cookieName = $feature->getCookieName(); // Early return if cookie // override is present if (Cookies::exists( $cookieName )) { return Cookies::get( $cookieName ); } // Evaluate feature toggle... } } 

Comment puis-je tester cette première réponse?

C'est vrai. Pas question.

Pour le tester, nous devons comprendre le comportement de la classe Cookies et être sûrs que nous pouvons reproduire tout l'environnement qui lui est associé, résultant en certaines réponses.

Ne fais pas ça.

La situation peut être corrigée si vous implémentez une instance de Cookies tant que dépendance. Le test ressemblera à ceci:

 // Test class... private Cookies $cookieMock; private FeatureToggle $service; // Preparing our service and dependencies public function setUp(): void { $this->cookieMock = $this->prophesize( Cookies::class ); $this->service = new FeatureToggle( $this->cookieMock->reveal() ); } public function testIsActiveIsOverriddenByCookies(): void { // Given $feature = $this->givenFeatureXExists(); // When $this->whenCookieOverridesFeatureWithTrue( $feature ); // Then $this->assertTrue( $this->service->isActive($feature) ); // additionally we can assert // no other methods were called } private function givenFeatureXExists(): Id { // ... return $feature; } private function whenCookieOverridesFeatureWithTrue( Id $feature ): void { $cookieName = $feature->getCookieName(); $this->cookieMock->exists($cookieName) ->shouldBeCalledOnce() ->willReturn(true); $this->cookieMock->get($cookieName) ->shouldBeCalledOnce() ->willReturn(true); } 

Il en va de même pour les singletones. Donc, si vous voulez rendre un objet unique, configurez correctement votre injecteur de dépendances, plutôt que d'utiliser le modèle (anti) singleton. Sinon, vous écrirez des méthodes qui ne sont utiles que pour des cas comme reset() ou setInstance() . À mon avis, c'est fou.

Il est tout à fait normal de changer d’architecture pour faciliter les tests! Et créer des méthodes pour faciliter les tests n'est pas normal.

5. Ne testez jamais les méthodes protégées / privées


La raison principale : elles affectent la façon dont nous testons les fonctions en déterminant la signature du comportement: dans une telle condition, lorsque j'entre A, je m'attends à obtenir B. Les méthodes privées / protégées ne font pas partie des signatures de fonction .

Je ne veux même pas montrer un moyen de "tester" des méthodes privées, mais je vais vous donner un indice: vous ne pouvez le faire qu'en utilisant l'API de réflexion .

Toujours vous punir d'une manière ou d'une autre lorsque vous pensez utiliser la réflexion pour tester des méthodes privées! Mauvais, mauvais développeur!

Par définition, les méthodes privées ne sont appelées qu'en interne. Autrement dit, ils ne sont pas accessibles au public. Cela signifie que seules les méthodes publiques de la même classe peuvent appeler de telles méthodes.

Si vous avez testé toutes vos méthodes publiques, vous avez également testé toutes les méthodes privées / protégées . Si ce n'est pas le cas, supprimez librement les méthodes privées / protégées; personne ne les utilise de toute façon.

Conseils avancés


J'espère que vous ne vous ennuyez pas encore. Pourtant, je devais parler des bases. Je vais maintenant partager mon opinion sur la rédaction de tests et de décisions propres qui affectent mon processus de développement.

La chose la plus importante que je n'oublie pas lors de l'écriture des tests:

  • Étude.
  • Rétroaction rapide.
  • La documentation
  • Refactoring
  • Conception lors des tests.

1. Tests au début, pas à la fin


Valeurs : étude, retour d'information rapide, documentation, refactoring, conception lors des tests.

C'est la base de tout. L'aspect le plus important, qui comprend toutes les valeurs répertoriées. Lorsque vous écrivez des tests à l'avance, cela vous aide à comprendre d'abord comment le schéma «donné, quand, alors» doit être structuré. Ce faisant, vous documentez d'abord et, plus important encore, vous vous souvenez et définissez vos exigences comme les aspects les plus importants.

Est-il étrange d'entendre écrire des tests avant la mise en œuvre? Et imaginez à quel point il est étrange d'implémenter quelque chose, et lorsque vous testez pour le découvrir, toutes vos expressions «données quand, alors» n'ont pas de sens.

De plus, cette approche vérifiera vos attentes toutes les deux secondes. Vous obtenez des commentaires le plus rapidement possible. Quelle que soit la taille de la fonctionnalité, quelle que soit sa taille.

Les tests verts sont un domaine idéal pour le refactoring. L'idée principale: pas de tests - pas de refactoring. La refactorisation sans tests est tout simplement dangereuse.

Enfin, en définissant la structure «donné quand, alors», il deviendra évident pour vous quelles interfaces vos méthodes devraient avoir et comment elles devraient se comporter. Garder le test propre vous obligera également à prendre constamment différentes décisions architecturales. Cela vous obligera à créer des usines, des interfaces, à perturber l'héritage, etc. Et oui, les tests deviendront plus faciles!

Si vos tests sont des documents en direct expliquant le fonctionnement de l'application, il est impératif qu'ils soient clairs.

2. Mieux sans tests qu'avec de mauvais tests


Valeurs : étude, documentation, refactoring.

De nombreux développeurs pensent aux tests de cette façon: j'écrirai une fonctionnalité, je piloterai le framework de test jusqu'à ce que les tests couvrent un certain nombre de nouvelles lignes, et je les enverrai en fonctionnement.

Il me semble que vous devez prêter plus d'attention à la situation lorsqu'un nouveau développeur commence à travailler avec cette fonctionnalité. Que diront les tests à cette personne?

Les tests sont souvent déroutants si les noms ne sont pas suffisamment détaillés. Qu'est-ce qui est plus clair: testCanFly ou testCanFlyReturnsFalseWhenPersonHasNoWings ?

Si vos tests ne sont que du code désordonné qui fait que le framework couvre plus de lignes, avec des exemples qui n'ont pas de sens, alors il est temps de s'arrêter et de penser à écrire ces tests.

Même un non-sens tel que l'attribution de $a et $b variables, ou l'attribution de noms qui ne sont pas liés à une utilisation spécifique.

N'oubliez pas : vos tests sont des documents en direct qui tentent d'expliquer le comportement de votre application. assertFalse($a->canFly()) documente pas beaucoup. Et assertFalse($personWithNoWings->canFly()) est déjà beaucoup.

3. Exécutez les tests de manière intrusive


Valeurs : étude, rétroaction rapide, refactoring.

Avant de commencer à travailler sur les fonctionnalités, exécutez les tests. S'ils échouent avant de vous mettre au travail, vous en serez informé avant d'écrire le code, et vous n'aurez pas à passer de précieuses minutes à déboguer des tests brisés dont vous ne vous souciiez même pas.

Après avoir enregistré le fichier, exécutez les tests. Plus tôt vous découvrirez que quelque chose s'est cassé, plus vite vous le réparerez et avancerez. Si l'interruption du flux de travail pour résoudre un problème vous semble improductive, alors imaginez que plus tard vous devrez revenir en arrière de nombreuses étapes si vous ne connaissez pas le problème.

Après avoir discuté avec des collègues pendant cinq minutes ou vérifié les notifications de Github, exécutez les tests. S'ils rougissaient, alors vous savez où vous en étiez. Si les tests sont verts, vous pouvez continuer à travailler.
Après tout refactoring, même les noms de variables, exécutez les tests.

Sérieusement, exécutez les fichus tests. Aussi souvent que vous appuyez sur le bouton Enregistrer.
PHPUnit Watcher peut le faire pour vous, et même envoyer des notifications!

4. Grands tests - grande responsabilité


Valeurs : étude, refactoring, conception lors des tests.

Idéalement, chaque classe devrait avoir un test. Ce test devrait couvrir toutes les méthodes publiques de cette classe, ainsi que chaque expression conditionnelle ou opérateur de transition ...

Vous pouvez prendre quelque chose comme ça:

  • Une classe = un cas de test.
  • Une méthode = un ou plusieurs tests.
  • Une branche alternative (si / switch / try-catch / exception) = un test.

Donc, pour ce code simple, vous aurez besoin de quatre tests:

 // class Person public function eatSlice(Pizza $pizza): void { // test exception if ([] === $pizza->slices()) { throw new LogicException('...'); } // test exception if (true === $this->isFull()) { throw new LogicException('...'); } // test default path (slices = 1) $slices = 1; // test alternative path (slices = 2) if (true === $this->isVeryHungry()) { $slices = 2; } $pizza->removeSlices($slices); } 

Plus vous disposez de méthodes publiques, plus vous aurez besoin de tests.

Personne n'aime lire une longue documentation. Puisque vos tests sont également des documents, la petite taille et la signification ne feront qu'augmenter leur qualité et leur utilité.

C'est aussi un signal important que votre classe accumule des responsabilités et il est temps de la refactoriser en transférant un certain nombre de fonctions vers d'autres classes ou en repensant le système.

5. Prise en charge d'un ensemble de tests pour résoudre les problèmes de régression


Valeurs : étude, documentation, rétroaction rapide.

Considérez la fonction:

 function findById(string $id): object { return fromDb((int) $id); } 

Vous pensez que quelqu'un transmet «10», mais en réalité «10 bananes» sont transmises. Autrement dit, deux valeurs viennent, mais une est superflue. Vous avez un bug.

Que ferez-vous en premier? Écrivez un test qui marquera un tel comportement erroné !!!

 public function testFindByIdAcceptsOnlyNumericIds(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( 'Only numeric IDs are allowed.' ); findById("10 bananas"); } 

Bien sûr, les tests ne transmettent rien. Mais maintenant, vous savez ce qui doit être fait pour qu'ils transmettent. Corrigez l'erreur, rendez les tests verts, déployez l'application et soyez heureux.

Gardez ce test avec vous. Dans la mesure du possible, dans un ensemble de tests conçus pour résoudre les problèmes de régression.

C'est tout! Commentaires rapides, corrections de bugs, documentation, code résistant à la régression et bonheur.

Le dernier mot


Une grande partie de ce qui précède n'est que mon opinion personnelle, développée au cours de ma carrière. Cela ne signifie pas que le conseil est vrai ou faux, c'est juste une opinion.

Source: https://habr.com/ru/post/fr485124/


All Articles