PHPUnit. "Comment puis-je tester mon putain de contrôleur", ou tester les sceptiques

Salut, Habr.

image

Oui, c'est un autre article sur le thème des tests. Il semblerait qu'ici il soit déjà possible de discuter? Tous ceux qui en ont besoin - ils écrivent des tests, qui n’en ont pas besoin - ils n’écrivent pas, tout le monde est content! Le fait est que la plupart des articles sur les tests unitaires ont ... comment offenser personne ... des exemples idiots! Non, vraiment! Aujourd'hui, je vais essayer de le réparer. Je demande un chat.

Et donc, une recherche rapide sur le thème des tests trouve juste beaucoup d'articles, qui dans leur masse sont divisés en deux catégories:

1) Le bonheur d'un rédacteur publicitaire. Nous voyons d'abord une longue introduction, puis l'histoire des tests unitaires dans la Russie antique, puis dix hacks de vie avec des tests, et à la fin un exemple. Avec des tests de code comme celui-ci:

<?php class Calculator { public function plus($a, $b) { return $a + $b; } } 

Et je ne plaisante pas pour le moment. J'ai vraiment vu des articles avec une «calculatrice» comme guide d'étude. Oui, oui, je comprends que pour commencer il faut tout simplifier, abstractions, va-et-vient ... Mais c'est là que tout se termine! Et puis finir le hibou, comme on dit

2) Exemples trop sophistiqués. Écrivons un test, entassons-le dans Gitlab CI, puis nous le corrigerons automatiquement si le test réussit, et nous appliquerons Infection PHP aux tests, mais nous connecterons tout à Hudson. Et ainsi de suite dans ce style. Cela semble utile, mais il semble que ce ne soit pas du tout ce que vous recherchez. Mais vous voulez juste augmenter légèrement la stabilité de votre projet. Et toutes ces continuités - enfin, pas toutes à la fois.

En conséquence, les gens doutent: "Mais en ai-je besoin?" À mon tour, je veux essayer d'expliquer plus clairement les tests. Et faites une réservation tout de suite - je suis développeur, je ne suis pas testeur. Je suis sûr que moi-même je ne sais pas grand-chose et mon premier mot dans ma vie n'était pas le mot «mok». Je n'ai même jamais travaillé sur TDD! Mais je sais avec certitude que même mon niveau actuel de compétences m'a permis de couvrir plusieurs projets avec des tests, et ces mêmes tests ont déjà détecté une douzaine de bugs. Et si cela m'a aidé, cela pourrait aider quelqu'un d'autre. Certains bogues capturés seraient difficiles à détecter manuellement.

Pour commencer, un court programme éducatif au format question-réponse:

Q: Dois-je utiliser une sorte de framework? Et si j'ai Yii? Et si Kohana? Et si% one_more_framework_name%?
R: Non, PHPUnit est un framework de test indépendant, vous pouvez même le visser au code hérité sur un framework self-made.

Q: Et maintenant, je parcoure rapidement le site avec mes mains, et c'est normal. Pourquoi en ai-je besoin?
R: L'exécution de plusieurs dizaines de tests dure plusieurs secondes. Les tests automatiques sont toujours plus rapides que les tests manuels, et avec des tests de haute qualité, ils sont également plus fiables, car ils couvrent tous les scénarios.

Q: J'ai un code hérité avec des fonctions de 2000 lignes. Puis-je tester cela?
R: Oui et non. En théorie, oui, tout code peut être couvert par un test. En pratique, le code doit être écrit avec une base pour les tests futurs. Une fonction de ligne 2000 aura trop de dépendances, de branches, de cas frontières. Il se peut que cela finisse par couvrir tout cela, mais cela vous prendra probablement un temps inacceptable. Plus le code est bon, plus il est facile de le tester. Plus la responsabilité unique est respectée, plus les tests seront faciles. Pour tester le plus souvent d'anciens projets, vous devez d'abord les refactoriser froidement.

image

Q: J'ai des méthodes (fonctions) très simples, qu'y a-t-il à tester? Tout y est fiable, il n'y a pas de place à l'erreur!
R: Il faut comprendre que vous ne testez pas l'implémentation correcte de la fonction (si vous n'avez pas TDD), vous «fixez» simplement son état de fonctionnement actuel. À l'avenir, lorsque vous devrez le modifier, vous pourrez rapidement déterminer si vous avez rompu son comportement à l'aide du test. Exemple: il existe une fonction qui valide le courrier électronique. Elle en fait un habitué.

 function isValid($email) { $regex = "very_complex_regex_here"; if (is_array($email)) { $result = true; foreach ($email as $item) { if (preg_match($regex, $item) === 0) { $result = false; } } } else { $result = preg_match($regex, $emai) ==! 0; } return $result; } 

Tout votre code s'attend à ce que si vous passez un e-mail valide à cette fonction, il reviendra vrai. Un tableau d'e-mails valides est également vrai. Un tableau avec au moins une adresse e-mail non valide est faux. Eh bien et ainsi de suite, le code est clair. Mais le jour est venu, et vous avez décidé de remplacer la monstrueuse saison régulière par une API externe. Mais comment garantir que la fonction réécrite n'a pas changé le principe de fonctionnement? Soudain, il ne gère pas bien le tableau? Ou reviendra-t-il pas booléen? Et les tests peuvent garder cela sous contrôle. Un test bien écrit indiquera immédiatement un comportement de fonction autre que prévu.

Q: Quand vais-je commencer à voir le sens des tests?
R: Premièrement, dès que vous couvrez une partie importante du code. Plus la couverture est proche de 100%, plus les tests sont fiables. Deuxièmement, dès que vous devez apporter des modifications globales ou des modifications dans la partie complexe du code. Les tests peuvent détecter des problèmes qui peuvent être facilement ignorés manuellement (cas limites). Troisièmement, lors de la rédaction des tests eux-mêmes! Il arrive souvent que l'écriture d'un test révèle des défauts de code qui ne sont pas visibles à première vue.

Q: Eh bien, j'ai un site Web sur laravel. Le site n'est pas une fonction, le site est une montagne de code merdique. Comment tester ici?
R: C'est ce qui sera discuté plus tard. En bref: nous testons séparément les méthodes des contrôleurs, séparément le middleware, séparément les services, etc.

L'une des idées des tests unitaires consiste à isoler la section de code testée. Moins vous testez de code avec un seul test, mieux c'est. Regardons un exemple aussi proche que possible de la vie réelle:

 <?php class Controller { public function __construct($userService, $emailService) { $this->userService = $userService; $this->emailService = $emailService; } public function login($request) { if (empty($request->login) || empty($request->password)) { return "Auth error"; } $password = $this->userService->getPasswordFor($request->login); if (empty($password)) { return "Auth error - no password"; } if ($password !== $request->password) { return "Incorrect password"; } $this->emailService->sendEmail($request->login); return "Success"; } } // .... /* somewhere in project core */ $controller = new Controller($userService, $emailService); $controller->login($request); 

Il s'agit d'une méthode très typique de connexion au système sur de petits projets. Tout ce que nous attendons, ce sont les messages d'erreur corrects et l'e-mail envoyé en cas de connexion réussie. Comment tester cette méthode? Tout d'abord, vous devez identifier les dépendances externes. Dans notre cas, il y en a deux - $ userService et $ emailService. Ils sont passés par le constructeur de classe, ce qui facilite grandement notre tâche. Mais, comme mentionné précédemment, moins nous testons de code en un seul passage, mieux c'est.

L'émulation, la substitution d'objets est appelée mokanem (de l'anglais. Mock object, littéralement: "object-parody"). Personne ne prend la peine d'écrire de tels objets manuellement, mais tout a été inventé avant nous, alors une bibliothèque aussi merveilleuse que Mockery vient à la rescousse. Créons des mokas pour les services.

 $userService = Mockery::mock('user_service'); $emailService = Mockery::mock('email_service'); 

Créez maintenant l'objet $ request. Pour commencer, nous allons tester la logique de vérification des champs de connexion et de mot de passe. Nous voulons être sûrs que s'il n'y en a pas, notre méthode traitera correctement ce cas et retournera le message (!) Souhaité.

 function testEmptyLogin() { $userService = Mockery::mock('user_service'); $emailService = Mockery::mock('email_service'); $controller = new Controller($userService, $emailService); $request = (object) []; $result = $controller->login($request); } 

Rien de compliqué, non? Nous avons créé des stubs pour les paramètres de classe nécessaires, créé une instance de la classe souhaitée et "tiré" la méthode souhaitée, en passant une demande délibérément erronée. J'ai une réponse. Mais comment le vérifier maintenant? C'est la partie la plus importante du test - la soi-disant assertion. PHPUnit a des dizaines d' assertions prêtes à l'emploi. Utilisez simplement l'un d'eux.

 function testEmptyLogin() { $userService = Mockery::mock('user_service'); $emailService = Mockery::mock('email_service'); $controller = new Controller($userService, $emailService); $request = (object) []; $result = $controller->login($request); // vv assertion here! vv $this->assertEquals("Auth error", $result); } 

Ce test garantit ce qui suit - si l'argument de connexion arrive à l'objet de méthode qui n'a pas le champ de connexion ou de mot de passe, alors la méthode renverra la chaîne "Auth error". C'est, en général, tout. Si simple - mais si utile, car nous pouvons maintenant modifier la méthode de connexion sans craindre de casser quelque chose. Notre frontend peut être sûr que si quelque chose se produit - il obtiendra exactement une telle erreur. Et si quelqu'un casse ce comportement (par exemple, décide de changer le texte d'erreur), le test le signalera immédiatement! Nous ajoutons les chèques restants pour couvrir autant de scénarios possibles que possible.

 function testEmptyPassword() { $userService = Mockery::mock('user_service'); // $userService->getPasswordFor(__any__arg__); // '' $userService->shouldReceive('getPasswordFor')->andReturn(''); $emailService = Mockery::mock('email_service'); $request = (object) [ 'login' => 'john', 'pass' => '1234' ]; $result = (new Controller($userService, $emailService))->login($request); $this->assertEquals("Auth error - no password", $result); } function testUncorrectPassword() { $userService = Mockery::mock('user_service'); // $userService->getPasswordFor(__any__arg__); // '4321' $userService->shouldReceive('getPasswordFor')->andReturn('4321'); $emailService = Mockery::mock('email_service'); $request = (object) [ 'login' => 'john', 'pass' => '1234' ]; $result = (new Controller($userService, $emailService))->login($request); $this->assertEquals("Incorrect password", $result); } function testSuccessfullLogin() { $userService = Mockery::mock('user_service'); // $userService->getPasswordFor(__any__arg__); // '1234' $userService->shouldReceive('getPasswordFor')->andReturn('1234'); $emailService = Mockery::mock('email_service'); $request = (object) [ 'login' => 'john', 'pass' => '1234' ]; $result = (new Controller($userService, $emailService))->login($request); $this->assertEquals("Success", $result); } 

Remarquez les méthodes shouldReceive et andReturn? Ils nous permettent de créer des méthodes dans des talons qui ne renvoient que ce dont nous avons besoin. Besoin de tester la mauvaise erreur de mot de passe? Nous écrivons un stub $ userService qui renvoie toujours le mauvais mot de passe. Et c'est tout.

Et qu'en est-il des dépendances, demandez-vous. Nous les avons ensuite «noyés» et que se passe-t-il s’ils se cassent? Mais c'est exactement à cela que sert la couverture de code maximale avec les tests. Nous ne vérifierons pas le fonctionnement de ces services dans le cadre de la connexion - nous testerons la connexion dans l'espoir du bon fonctionnement des services. Et puis nous écrivons les mêmes tests isolés pour ces services. Et puis teste leurs dépendances. Et ainsi de suite. Par conséquent, chaque test individuel garantit uniquement le bon fonctionnement d'un petit morceau de code, à condition que toutes ses dépendances fonctionnent correctement. Et comme toutes les dépendances sont également couvertes par des tests, leur bon fonctionnement est également garanti. En conséquence, toute modification du système qui rompt la logique du travail même du plus petit morceau de code apparaîtra immédiatement dans un test particulier. Comment exécuter spécifiquement le test - Je ne dirai pas, la documentation de PHPUnit est assez bonne. Et dans Laravel, par exemple, il suffit d'exécuter vendor / bin / phpunit à partir de la racine du projet pour voir un message comme celui-ci

image - Tous les tests ont réussi. Ou quelque chose comme ça

image L'une des sept affirmations a échoué.

"Bien sûr, c'est cool, mais qu'est-ce que je ne peux pas mettre la main sur?" Vous demandez. Et imaginons le code suivant pour cela

 <?php function getInfo($infoApi, $userName) { $response = $infoApi->getInfo($userName); if ($response->status === "API Error") { return null; } return $response->result; } // ... somewhere in system $api = new ExternalApi(); $info = getInfo($api, 'John'); if ($info === null) { die('Api is down'); } echo $info; 

Nous voyons un modèle simplifié de travail avec une API externe. La fonction utilise une classe pour fonctionner avec l'API et, en cas d'erreur, renvoie null. Si, lors de l'utilisation de cette fonction, nous obtenons null, nous devons "déclencher la panique" (envoyer un message au jeu, ou envoyer un e-mail au développeur, ou jeter une erreur dans le kibana. Oui, un tas d'options). Tout semble simple, non? Mais imaginez qu'après un certain temps, un autre développeur a décidé de «réparer» cette fonction. Il a décidé que le retour nul est le siècle dernier et il devrait lever une exception.

 function getInfo($infoApi, $userName): string { $response = $infoApi->getInfo($userName); if ($response->status === "API Error") { throw new ApiException($response); } return $response->result; } 

Et il a même réécrit toutes les sections du code où cette fonction était appelée! Tous sauf un. Il lui manquait. Distrait, fatigué, juste faux - mais on ne sait jamais. Le fait est qu'un morceau de code attend toujours l'ancien comportement de la fonction. Et PHP n'est pas Java pour nous - nous n'obtiendrons pas d'erreur de compilation au motif que la fonction jetable n'est pas encapsulée dans try-catch. Par conséquent, dans l'un des 100 scénarios d'utilisation du site, en cas de baisse de l'API, nous ne recevrons pas de message du système. De plus, avec des tests manuels, nous ne verrons probablement pas cette version de l'événement. L'API est externe, elle ne dépend pas de nous, elle fonctionne bien - et très probablement nous ne la mettrons pas la main en cas de panne de l'API et de gestion incorrecte des exceptions. Mais si nous avons des tests, ils intercepteront très bien ce cas, car la classe ExternalApi est "étouffée" dans un certain nombre de tests, et elle émule à la fois un comportement normal et un crash. Et le prochain test va tomber

 function testApiFail() { $api = Mockery::mock('api'); $api->shouldReceive('getInfo')->andReturn((object) [ 'status' => 'API Error' ]); $result = getInfo($api, 'name'); $this->assertNull($result); } 

Cette information est en fait suffisante. Si vous n'avez pas de nouilles Legacy, après 20-30 minutes, vous pouvez passer votre premier test. Et quelques semaines plus tard - pour apprendre quelque chose de nouveau, cool, revenez aux commentaires sous ce post, et écrivez quel auteur le govnokoder ne connaît pas% framework_name%, et écrit de mauvais tests, mais vous devez faire% this_way%. Et je serai très heureux dans ce cas. Cela signifiera que mon objectif a été atteint: quelqu'un d'autre a découvert les tests par lui-même et a un peu augmenté le niveau général de professionnalisme dans notre domaine!

Une critique raisonnée est la bienvenue.

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


All Articles