Tests unitaires et Python



Je m'appelle Vadim, je suis l'un des principaux développeurs de Mail.Ru Search. Je partagerai notre expérience des tests unitaires. L'article se compose de trois parties: dans la première, je vais vous dire ce que nous réalisons généralement à l'aide de tests unitaires; la deuxième partie décrit les principes que nous suivons; et de la troisième partie, vous apprendrez comment les principes mentionnés sont implémentés en Python.

Buts


Il est très important de comprendre pourquoi vous appliquez des tests unitaires. Des actions concrètes en dépendront. Si vous utilisez les tests unitaires de manière incorrecte, ou avec leur aide vous ne faites pas ce que vous vouliez, alors rien de bon n'en sortira. Par conséquent, il est très important de comprendre à l'avance quels objectifs vous poursuivez.

Dans nos projets, nous poursuivons plusieurs objectifs.

La première est une régression banale: pour corriger quelque chose dans le code, lancez les tests et découvrez que rien ne s'est cassé. Bien que, en fait, ce ne soit pas aussi simple qu'il y paraît.

Le deuxième objectif est d'évaluer l'impact de l'architecture . Si vous introduisez des tests unitaires obligatoires dans le projet, ou si vous êtes simplement d'accord avec les développeurs sur l'utilisation des tests unitaires, cela affectera immédiatement le style d'écriture du code. Il est impossible d'écrire des fonctions sur 300 lignes avec 50 variables locales et 15 paramètres si ces fonctions sont soumises à des tests unitaires. De plus, grâce à ces tests, les interfaces deviendront plus compréhensibles et certaines zones problématiques apparaîtront. Après tout, si le code n'est pas si chaud, le test sera une courbe et il attirera immédiatement votre attention.

Le troisième objectif est de rendre le code plus clair . Supposons que vous soyez arrivé à un nouveau projet et que vous ayez reçu 50 Mo de code source. Vous ne pourrez peut-être tout simplement pas les comprendre. S'il n'y a pas de tests unitaires, alors la seule façon de se familiariser avec le travail du code, en plus de lire la source, est la «méthode poke». Mais si le système est assez compliqué, cela peut prendre beaucoup de temps pour obtenir les morceaux de code nécessaires via l'interface. Et grâce aux tests unitaires, vous pouvez voir comment le code est exécuté de n'importe où.

Le quatrième objectif est de simplifier le débogage . Par exemple, vous avez trouvé une classe et souhaitez la déboguer. Si au lieu de tests unitaires, il n'y a que des tests système, ou pas de tests du tout, alors il ne reste plus qu'à arriver au bon endroit via l'interface. Il m'est arrivé de participer à un projet où, pour tester certaines fonctionnalités, il a fallu une demi-heure pour créer un utilisateur, lui faire payer de l'argent, changer son statut, lancer une sorte de cron, afin que ce statut soit transféré ailleurs, puis cliquer sur quelque chose dans l'interface, lancer quelque chose un autre cron ... Après une demi-heure, un programme de bonus pour cet utilisateur est finalement apparu. Et si j'avais des tests unitaires, je pourrais immédiatement me rendre au bon endroit.

Enfin, le but le plus important et le plus abstrait, qui unit tous les précédents, est le confort . Lorsque je fais des tests unitaires, je ressens moins de stress lorsque je travaille avec du code, car je comprends ce qui se passe. Je peux prendre une source inconnue, corriger trois lignes, exécuter des tests et m'assurer que le code fonctionne comme prévu. Et ce n'est même pas que les tests soient verts: ils peuvent être rouges, mais exactement là où je m'attends. Autrement dit, je comprends comment fonctionne le code.

Principes


Si vous comprenez vos objectifs, vous pouvez comprendre ce qui doit être fait pour les atteindre. Et ici les problèmes commencent. Le fait est que de nombreux livres et articles ont été écrits sur les tests unitaires, mais la théorie est encore très immature.

Si vous avez déjà lu des articles sur les tests unitaires, essayé d’appliquer ce qui est décrit et que vous n’avez pas réussi, il est très probable que la raison soit l’imperfection de la théorie. Cela arrive tout le temps. Comme tous les développeurs, j'ai pensé un jour que le problème était en moi. Et puis il a réalisé: il ne peut pas être que je me suis trompé tant de fois. Et il a décidé que dans les tests unitaires, il fallait partir de ses propres considérations, pour agir de manière plus sensée.

Le conseil standard que vous pouvez trouver dans tous les livres et articles: «vous devez tester non pas l'implémentation, mais l'interface». Après tout, l'implémentation peut changer, mais pas l'interface. Essayons-le pour que les tests ne tombent pas tout le temps à chaque occasion. Le conseil, semble-t-il, n'est pas mauvais, et tout semble logique. Mais nous le savons très bien: pour tester quelque chose, vous devez sélectionner des valeurs de test. Habituellement, lors du test de fonctions, les classes dites d'équivalence sont distinguées: l'ensemble de valeurs auquel la fonction se comporte uniformément. En gros, le test pour chaque si. Mais pour savoir quelles classes d'équivalence nous avons, une implémentation est nécessaire. Vous ne le testez pas, mais vous en avez besoin, vous devez l'examiner afin de savoir quelles valeurs de test choisir.

Parlez à n'importe quel testeur: il vous dira qu'avec des tests manuels, il imagine toujours une implémentation. D'après son expérience, il comprend parfaitement où les programmeurs font généralement des erreurs. Le testeur ne vérifie pas tout, saisissant d'abord 5, puis 6, puis 7. Il vérifie 5, abc, –7, et le nombre est de 100 caractères, car il sait que l'implémentation de ces valeurs peut différer, mais pour 6 et 7, il est peu probable .

Il n'est donc pas clair comment suivre le principe de "tester l'interface, pas l'implémentation". Vous ne pouvez pas simplement prendre, fermer les yeux et écrire un test. TDD essaie de résoudre ce problème en partie. La théorie suggère d'introduire les classes d'équivalence une à la fois et d'écrire des tests pour elles. J'ai lu beaucoup de livres et d'articles sur ce sujet, mais d'une manière ou d'une autre ça ne colle pas. Cependant, je suis d'accord avec la thèse selon laquelle les tests devraient être écrits en premier. Nous appelons ce test de principe en premier. Nous n'avons pas TDD, et en relation avec ce qui précède, les tests ne sont pas écrits avant la création du code, mais en parallèle avec lui.

Je ne recommande certainement pas d'écrire des tests rétroactivement. Après tout, ils influencent l'architecture, et si elle s'est déjà installée, alors il est trop tard pour l'influencer - tout devra être réécrit. En d'autres termes, la testabilité du code est une propriété distincte que le code devra doter , il ne le deviendra pas. Par conséquent, nous essayons d'écrire des tests avec du code. Ne croyez pas aux histoires comme «écrivons un projet dans trois mois, puis couvrons tout avec des tests dans une semaine», cela n'arrivera jamais.

La chose la plus importante à comprendre: les tests unitaires ne sont pas un moyen de vérifier le code, pas un moyen de vérifier son exactitude. Cela fait partie de votre architecture, de la conception de votre application. Lorsque vous travaillez avec des tests unitaires, vous changez vos habitudes. Les tests qui vérifient uniquement l'exactitude sont plutôt des tests d'acceptation. Ce sera une erreur de penser que vous pouvez ensuite couvrir quelque chose avec des tests unitaires, ou qu'alors le code n'aura pas besoin d'être vérifié.

Implémentation de Python


Nous utilisons la bibliothèque standard la plus unitaire de la famille xUnit. L'histoire est la suivante: il y avait le langage SmallTalk, et en lui la bibliothèque SUnit. Tout le monde l'a aimé, ils ont commencé à le copier. La bibliothèque a été importée en Java sous le nom Junit, à partir de là en C ++ sous le nom CppUnit et en Ruby sous le nom RUnit (puis elle a été renommée RSpec). Enfin, à partir de Java, la bibliothèque a «migré» vers Python sous le nom unittest. Et ils l'ont importé si littéralement que même CamelCase est resté, bien que cela ne corresponde pas à PEP 8.

À propos de xUnit, il y a un merveilleux livre, «xUnit Test Patterns». Il décrit comment travailler avec les cadres de cette famille. Le seul inconvénient du livre est sa taille: il est énorme, mais environ 2/3 du contenu est un catalogue de motifs. Et le premier tiers du livre est tout simplement merveilleux, c'est l'un des meilleurs livres sur l'informatique que j'ai rencontré.

Un test unitaire est un code standard qui a une certaine architecture standard. Tous les tests unitaires se composent de trois étapes: configuration, exercice et vérification. Vous préparez les données, exécutez les tests et voyez si tout est dans le bon état.



Configuration


L'étape la plus difficile et intéressante. Ramener le système à son état d'origine à partir duquel vous souhaitez le tester peut être très difficile. Et l'état du système peut être arbitrairement complexe.

Au moment où votre fonction est appelée, de nombreux événements auraient pu se produire, un million d'objets auraient pu être créés en mémoire. Dans tous les composants associés à votre logiciel - dans le système de fichiers, la base de données, les caches - quelque chose est déjà localisé, et la fonction ne peut fonctionner que dans cet environnement. Et si l'environnement n'est pas préparé, alors les actions de la fonction n'auront aucun sens.

En général, tout le monde prétend que vous ne pouvez en aucun cas utiliser des systèmes de fichiers, des bases de données ou tout autre composant distinct, car cela rend votre test non modulaire, mais d'intégration. À mon avis, ce n'est pas vrai, car le test d'intégration est effectué par le test d'intégration. Si vous utilisez certains composants non pas pour la vérification, mais simplement pour faire fonctionner le système, il n'y a rien de mal à cela. Votre code interagit avec de nombreux composants de l'ordinateur et du système d'exploitation. Le seul problème avec l'utilisation d'un système de fichiers ou d'une base de données est la vitesse.

Directement dans le code, nous utilisons l' injection de dépendances . Vous pouvez lancer des paramètres dans la fonction au lieu des paramètres par défaut. Vous pouvez même transférer des liens vers des bibliothèques. Ou vous pouvez glisser un talon au lieu d'une demande afin que le code des tests n'accède pas au réseau. Vous pouvez stocker des enregistreurs personnalisés dans les attributs de classe afin de ne pas écrire sur le disque et gagner du temps.

Pour les talons, nous utilisons la maquette habituelle de unittest. Il existe également une fonction de correctif qui, au lieu d'implémenter honnêtement des dépendances, dit simplement: «dans ce package, cette importation remplace une autre». C'est pratique car vous n'avez rien à jeter n'importe où. Certes, il n'est pas clair qui a remplacé quoi, alors utilisez-le soigneusement.

Quant au système de fichiers, il est assez simple de simuler. Il existe un module io avec io.StringIO et io.BytesIO . Vous pouvez créer des objets de type fichier qui n’accèdent pas réellement au disque. Mais si tout d'un coup cela ne vous suffit pas, alors il y a un merveilleux module tempfile avec des gestionnaires de contexte pour les fichiers temporaires, les répertoires, les fichiers nommés, n'importe quoi. Tempfile est un supermodule si pour une raison quelconque IO ne vous convenait pas.

Avec une base de données, tout est plus compliqué. Il existe une recommandation standard: "N'utilisez pas une base réelle, mais fausse". Je ne sais pas pour vous, mais dans ma vie je n'ai pas vu une seule base fausse et suffisamment fonctionnelle. Chaque fois que je demandais des conseils sur ce qu'il fallait spécifiquement prendre sous Python ou Perl, ils répondaient que personne ne savait quoi que ce soit de prêt et proposaient d'écrire quelque chose d'eux-mêmes. Je ne peux pas imaginer comment vous pouvez écrire un émulateur, par exemple, PostgreSQL. Une autre astuce: "puis obtenez SQLite." Mais cela rompra l'isolement, car SQLite fonctionne avec le système de fichiers. De plus, si vous utilisez quelque chose comme MySQL ou PostgreSQL, alors SQLite ne fonctionnera probablement pas. S'il vous semble que vous n'utilisez pas les capacités spécifiques de produits spécifiques, vous vous trompez probablement. Certes, même pour des choses courantes, telles que l'utilisation de dates, vous utilisez des fonctionnalités spécifiques que seul votre SGBD prend en charge.

En conséquence, ils utilisent généralement une vraie base. La solution n'est pas mauvaise, seulement nous devons montrer une certaine précision. N'utilisez pas de base de données centralisée, car les tests peuvent se rompre entre eux. Idéalement, la base elle-même devrait monter pendant les tests et s'arrêter après les tests.

Une situation légèrement pire est lorsque vous devez exécuter une base de données locale, qui sera utilisée. Mais la question est de savoir comment les données y parviendront. Nous avons déjà dit qu'il doit y avoir un état initial du système, il doit y avoir des données dans la base de données. D'où ils viennent n'est pas une question facile.

L'approche la plus naïve que j'ai rencontrée consiste à utiliser une copie d'une véritable base de données. Une copie en a été régulièrement prélevée, dont les données sensibles ont été supprimées. Les auteurs ont estimé que les données réelles sont les mieux adaptées aux tests. De plus, l'écriture de tests pour une copie d'une vraie base de données est un tourment. Vous ne savez pas quelles données il y a. Vous devez d'abord trouver sur quoi vous allez tester. Si cette information n'est pas là, alors quoi faire n'est pas clair. Il s'est avéré que dans ce projet, ils ont décidé d'écrire des tests pour le compte du service des opérations, qui «ne changeront jamais». Bien sûr, après un certain temps, elle a changé.

Ceci est généralement suivi de la décision: «faisons un casting de la base réelle, copiez-la et ne synchronisez plus. Ensuite, il sera possible d'être lié à un objet spécifique, de regarder ce qui s'y passe et d'écrire des tests. » La question se pose immédiatement: que se passera-t-il lorsque de nouvelles tables seront ajoutées à la base de données? Apparemment, vous devrez saisir manuellement de fausses données.

Mais comme nous le ferons de toute façon, préparons immédiatement le casting de base manuellement. Cette option est très similaire à ce que l'on appelle généralement les fixtures dans Django: ils font un énorme JSON, téléchargent des cas de test pour toutes les occasions, les envoient à la base de données au début des tests, et tout ira bien pour nous. Cette approche présente également de nombreux inconvénients. Les données sont empilées en tas, on ne sait pas à quel test elles se rapportent. Personne ne peut comprendre si les données ont été supprimées ou non. Et il y a des états incompatibles de la base de données: par exemple, un test doit avoir aucun utilisateur dans la base de données, et l'autre pour les avoir. Ces deux conditions ne peuvent pas être stockées simultanément dans le même moule. Dans ce cas, l'un des tests devra modifier la base de données. Et comme vous devez encore gérer cela, il est plus facile de partir d'une base de données vide, de sorte que chaque test y mette les données nécessaires et, à la fin du test, il efface la base de données. Le seul inconvénient de cette approche est la difficulté de créer des données dans chaque test. Dans l'un des projets où j'ai travaillé, pour créer un service, il fallait générer 8 entités dans différentes tables: un service sur un compte personnel, un compte personnel sur un client, un client sur une entité juridique, une entité juridique dans une ville, un client dans une ville, etc. Jusqu'à ce que vous créiez tout cela dans une chaîne, vous ne satisferez pas la clé étrangère, rien ne fonctionne.

Pour de telles situations, il existe des bibliothèques spéciales qui facilitent grandement la vie. Vous pouvez écrire des outils auxiliaires, généralement appelés usines (ne pas confondre avec le modèle de conception). Par exemple, nous avons utilisé la bibliothèque factory_boy, qui convient à Django. Il s'agit d'un clone de la bibliothèque factory_girl, qui a été renommée factory_bot l'année dernière pour des raisons d'exactitude politique. Écrire une telle bibliothèque pour votre propre framework ne coûte rien. Il est basé sur une idée très importante: vous créez une fabrique une fois pour les objets que vous souhaitez générer, établissez des connexions pour celle-ci, puis dites à l'utilisateur: «lorsque vous êtes créé, prenez un autre nom et générez vous-même le groupe à l'aide de la fabrique de groupe». Et dans l'usine, tout est exactement pareil: générer le nom de telle manière, les entités liées telles ou telles.

Par conséquent, une seule dernière ligne reste dans le code: user = UserFactory() . L'utilisateur a été créé, et vous pouvez travailler avec lui, car sous le capot, il a généré tout ce qui était nécessaire. Si vous le souhaitez, vous pouvez configurer quelque chose manuellement.

Pour nettoyer les données après le test, nous utilisons des transactions triviales. Au début de chaque test, BEGIN est terminé, le test fait quelque chose avec la base, et après le test, ROLLBACK est terminé. Si des transactions sont nécessaires dans le test lui-même - par exemple, parce qu'il engage quelque chose de plus dans la base de données - il appelle la méthode que nous avons appelée break_db , indique au framework qu'il a cassé la base de données, et le framework le relance. Cela se fait lentement, mais comme il y a généralement très peu de tests qui nécessitent des transactions, tout est en ordre.

Exercice


Il n'y a rien de spécial à dire sur cette étape. La seule chose qui peut mal tourner ici est de se tourner vers l'extérieur, par exemple, vers Internet. Pendant un certain temps, nous avons eu du mal avec cela administrativement: nous avons dit aux programmeurs que nous devons soit plonger les fonctions qui vont quelque part ou lancer des drapeaux spéciaux pour que les fonctions ne le soient pas. Si le test accède à etcd d'entreprise, ce n'est pas bon. En conséquence, nous sommes arrivés à la conclusion que tout était gaspillé: nous-mêmes oublions constamment que certaines fonctions appellent une fonction qui appelle une fonction qui va à etcd. Par conséquent, dans le setUp de la classe de base, nous avons ajouté le moki de tous les appels, c'est-à-dire bloqué à l'aide de stubs tous les appels où ils ne sont pas passés.

Les stubs peuvent être facilement créés à l'aide de patchers, les mettre dans un dictionnaire séparé et donner accès à tous les tests. Par défaut, les tests ne peuvent aller nulle part, et si pour certains vous avez encore besoin d'ouvrir l'accès, vous pouvez le rediriger. Très confortable. Jenkins n'enverra plus de SMS à vos clients la nuit :)

Vérifiez


À ce stade, nous utilisons activement des assertions auto-écrites, même des lignes simples. Si vous testez l'existence d'un fichier dans le test, alors au lieu d'affirmer self.assertTrue(file_exists(f)) recommande d'écrire affirmer self.assertTrue(file_exists(f)) not file exists . Holivar est lié à cela: dois-je continuer à utiliser CamelCase dans les noms, comme dans unittest, ou dois-je suivre PEP 8? Je n'ai pas de réponse. Si vous suivez PEP 8, alors dans le code de test, il y aura un gâchis de CamelCase et snake_case. Et si vous utilisez CamelCase, cela ne correspond pas à PEP 8.

Et le dernier. Supposons que vous ayez un code qui teste quelque chose et qu'il existe de nombreuses options de données sur lesquelles ce code doit être exécuté. Si vous utilisez py.test, vous pouvez exécuter le même test avec différentes données d'entrée. Si vous n'avez pas py.test, vous pouvez utiliser un tel décorateur . Une table est passée au décorateur, et un test se transforme en plusieurs autres, chacun testant l'un des cas.

Conclusion


Ne faites pas confiance aux articles et aux livres sans condition. Si vous pensez qu'ils ont tort, il est possible qu'il en soit ainsi.

N'hésitez pas à utiliser des tests de dépendance. Il n'y a rien de mal à cela. Si vous avez soulevé memcached, car sans lui, votre code ne fonctionne pas normalement, ça va. Mais il vaut mieux s'en passer, si possible.

Faites attention aux usines. C'est un schéma très intéressant.

PS Je vous invite à la chaîne Telegram de mon auteur pour la programmation en Python - @pythonetc.

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


All Articles