Lorsque vous écrivez des tests unitaires, n'utilisez pas de simulations

Publié: 2018-05-02

Remarque : Il s'agit de notre dernier article d'ingénierie technique rédigé par l'ingénieur principal, Seth Ammons. Remerciements particuliers à Sam Nguyen, Kane Kim, Elmer Thomas et Kevin Gillette pour avoir révisé ce message par leurs pairs . Et pour plus d'articles comme celui-ci, consultez notre blog technique.

J'aime beaucoup écrire des tests pour mon code, en particulier des tests unitaires. Le sentiment de confiance que cela me donne est formidable. Reprendre quelque chose sur lequel je n'ai pas travaillé depuis longtemps et être capable d'exécuter les tests unitaires et d'intégration me donne la connaissance que je peux impitoyablement refactoriser si nécessaire et, tant que mes tests ont une couverture bonne et significative et continuent à passer , j'aurai encore des logiciels fonctionnels par la suite.

Les tests unitaires guident la conception du code et nous permettent de vérifier rapidement que les modes de défaillance et les flux logiques fonctionnent comme prévu. Sur ce, je veux écrire sur quelque chose peut-être un peu plus controversé : lors de l'écriture de tests unitaires, n'utilisez pas de simulations.

Mettons quelques définitions sur la table

Quelle est la différence entre les tests unitaires et d'intégration ? Qu'est-ce que j'entends par simulacres et que devriez-vous utiliser à la place ? Ce message est axé sur le travail en Go, et donc mon point de vue sur ces mots est dans le contexte de Go.

Quand je dis tests unitaires , je fais référence à ces tests qui garantissent une bonne gestion des erreurs et guident la conception du système en testant de petites unités de code. Par unité, nous pouvons faire référence à un package complet, une interface ou une méthode individuelle.

Les tests d'intégration sont l'endroit où vous interagissez réellement avec des systèmes et/ou des bibliothèques dépendants. Quand je dis « simulacres », je fais spécifiquement référence au terme « objet fictif », qui est l'endroit où nous « remplaçons le code de domaine par des implémentations factices qui émulent à la fois des fonctionnalités réelles et appliquent des affirmations sur le comportement de notre code [1] » (emphase mien).

En un peu plus court : les simulations affirment le comportement, comme :

MyMock.Method("foo").Called(1).WithArgs("bar").Returns("raz")

Je plaide pour "Fakes plutôt que Mocks".

Un faux est une sorte de sosie de test qui peut contenir un comportement commercial [2]. Les faux sont simplement des structures qui correspondent à une interface et sont une forme d'injection de dépendances où nous contrôlons le comportement. Le principal avantage des fakes est qu'ils diminuent le couplage dans le code, où les mocks augmentent le couplage, et le couplage rend la refactorisation plus difficile [3].

Dans cet article, j'ai l'intention de démontrer que les contrefaçons offrent de la flexibilité et permettent de tester et de refactoriser facilement. Ils réduisent les dépendances par rapport aux simulations et sont faciles à entretenir.

Plongeons-nous dans un exemple un peu plus avancé que "tester une fonction de somme" comme vous pourriez le voir dans un article typique de cette nature. Cependant, je dois vous donner un peu de contexte afin que vous puissiez comprendre plus facilement le code qui suit dans cet article.

Chez SendGrid, l'un de nos systèmes avait traditionnellement des fichiers sur le système de fichiers local, mais en raison du besoin d'une plus grande disponibilité et d'un meilleur débit, nous transférons ces fichiers vers S3.

Nous avons une application qui doit être capable de lire ces fichiers et nous avons opté pour une application qui peut fonctionner en deux modes "local" ou "distant", selon la configuration. Une mise en garde qui est élidée dans de nombreux exemples de code est qu'en cas d'échec à distance, nous revenons à la lecture du fichier localement.

Avec cela à l'écart, cette application a un getter de paquet. Nous devons nous assurer que le getter de paquet peut obtenir des fichiers à partir du système de fichiers distant ou du système de fichiers local.

Approche naïve : appelez simplement les appels au niveau de la bibliothèque et du système

L'approche naïve est que notre package d'implémentation appellera getter.New(...) et lui transmettra les informations nécessaires pour configurer l'obtention de fichiers distants ou locaux et renverra un Getter . La valeur retournée pourra alors appeler MyGetter.GetFile(...) avec les paramètres nécessaires à la localisation du fichier distant ou local.

Cela nous donne notre structure de base. Lorsque nous créons le nouveau Getter , nous initialisons les paramètres nécessaires à toute récupération potentielle de fichiers à distance (une clé d'accès et un secret) et nous transmettons également certaines valeurs provenant de la configuration de notre application, telles que useRemoteFS qui indiquera au code d'essayer le système de fichiers distant.

Nous devons fournir certaines fonctionnalités de base. Découvrez le code naïf ici [4] ; ci-dessous est une version réduite. Notez qu'il s'agit d'un exemple non terminé et que nous allons refactoriser les choses.

L'idée de base ici est que si nous sommes configurés pour lire à partir du système de fichiers distant et que nous obtenons les détails du système de fichiers distant (hôte, compartiment et clé), nous devrions alors essayer de lire à partir du système de fichiers distant. Une fois que nous aurons confiance dans le système de lecture à distance, nous transférerons toute la lecture de fichiers vers le système de fichiers distant et supprimerons les références à la lecture du système de fichiers local.

Ce code n'est pas très convivial pour les tests unitaires ; notez que pour vérifier comment cela fonctionne, nous devons en fait toucher non seulement le système de fichiers local, mais également le système de fichiers distant. Maintenant, nous pourrions simplement faire un test d'intégration et mettre en place une magie Docker pour avoir une instance s3 nous permettant de vérifier le chemin heureux dans le code.

Avoir uniquement des tests d'intégration n'est pas idéal, car les tests unitaires nous aident à concevoir des logiciels plus robustes en testant facilement des codes alternatifs et des chemins de défaillance. Nous devrions réserver les tests d'intégration pour les types de tests plus larges "ça marche vraiment". Pour l'instant, concentrons-nous sur les tests unitaires.

Comment pouvons-nous rendre ce code plus testable unitaire ? Il existe deux écoles de pensée. L'une consiste à utiliser un générateur de simulation (tel que https://github.com/vektra/mockery ou https://github.com/golang/mock) qui crée un code passe-partout à utiliser lors des tests de simulation.

Vous pouvez suivre cette voie et générer les appels du système de fichiers et les appels du client Minio. Ou peut-être voulez-vous éviter une dépendance, vous générez donc vos simulations à la main. Il s'avère que se moquer du client Minio n'est pas simple car vous avez un client concrètement typé qui renvoie un objet concrètement typé.

Je dis qu'il y a un meilleur moyen que de se moquer. Si nous restructurons notre code pour qu'il soit plus testable, nous n'avons pas besoin d'importations supplémentaires pour les simulations et les crufts associés et il n'y aura pas besoin de connaître des DSL de test supplémentaires pour tester les interfaces en toute confiance. Nous pouvons configurer notre code pour qu'il ne soit pas trop couplé et le code de test sera simplement du code Go normal utilisant les interfaces de Go. Faisons-le!

Approche d'interface : plus grande abstraction, tests plus faciles

Qu'est-ce que nous devons tester ? C'est là que certains nouveaux Gophers se trompent. J'ai vu des gens comprendre la valeur de tirer parti des interfaces, mais ils ont le sentiment qu'ils ont besoin d'interfaces qui correspondent à l'implémentation concrète du package qu'ils utilisent.

Ils pourraient voir que nous avons un client Minio, ils pourraient donc commencer par créer des interfaces qui correspondent à TOUTES les méthodes et utilisations du client Minio (ou de tout autre client s3). Ils oublient le Go Proverb [5][6] de « Plus l'interface est grande, plus l'abstraction est faible ».

Nous n'avons pas besoin de tester avec le client Minio. Nous devons tester que nous pouvons obtenir des fichiers à distance ou localement (et vérifier certains chemins d'échec, tels que les échecs à distance). Refactorisons cette approche initiale et extrayons le client Minio dans un getter distant. Pendant que nous faisons cela, faisons la même chose avec notre code pour la lecture de fichiers locaux et créons un getter local. Voici les interfaces de base, et nous aurons le type pour implémenter chacune :

Avec ces abstractions en place, nous pouvons refactoriser notre implémentation initiale. Nous allons mettre le localFetcher et le remoteFetcher sur la structure Getter et refactoriser GetFile pour les utiliser. Découvrez la version complète du code refactorisé ici [7]. Vous trouverez ci-dessous un extrait légèrement simplifié utilisant la nouvelle version de l'interface :

Ce nouveau code refactorisé est beaucoup plus testable unitaire car nous prenons les interfaces comme paramètres sur la structure Getter et nous pouvons changer les types concrets pour les faux. Au lieu de se moquer des appels du système d'exploitation ou d'avoir besoin d'une simulation complète du client Minio ou des grandes interfaces, nous avons juste besoin de deux faux simples : fakeLocalFetcher et fakeRemoteFetcher .

Ces faux ont des propriétés qui nous permettent de spécifier ce qu'ils renvoient. Nous pourrons renvoyer les données du fichier ou toute erreur de notre choix et nous pourrons vérifier que la méthode GetFile appelante gère les données et les erreurs comme prévu.

Dans cette optique, le cœur des tests devient :

Avec cette structure de base, nous pouvons tout résumer dans des tests pilotés par des tableaux [8]. Chaque cas dans le tableau des tests testera l'accès aux fichiers local ou distant. Nous pourrons injecter une erreur lors de l'accès au fichier distant ou local. Nous pouvons vérifier les erreurs propagées, que le contenu du fichier est transmis et que les entrées de journal attendues sont présentes.

Je suis allé de l'avant et j'ai inclus tous les cas de test potentiels et les permutations dans le test basé sur une table disponible ici [9] (vous pouvez noter que certaines signatures de méthode sont un peu différentes - cela nous permet de faire des choses comme injecter un enregistreur et affirmer contre les déclarations de journal ).

Chouette, hein ? Nous avons un contrôle total sur la façon dont nous voulons que GetFile se comporte et nous pouvons nous opposer aux résultats. Nous avons conçu notre code pour être compatible avec les tests unitaires et pouvons désormais vérifier les chemins de réussite et d'erreur implémentés dans la méthode GetFile .

Le code est faiblement couplé et la refactorisation à l'avenir devrait être un jeu d'enfant. Nous l'avons fait en écrivant du code Go simple que tout développeur familier avec Go devrait être capable de comprendre et d'étendre si nécessaire.

Simulations : qu'en est-il des détails de mise en œuvre ?

Qu'est-ce que les simulacres nous achèteraient que nous n'obtenons pas dans la solution proposée ? Une excellente question présentant un avantage pour une simulation traditionnelle pourrait être : « comment savez-vous que vous avez appelé le client s3 avec les paramètres corrects ? Avec les simulations, je peux m'assurer que j'ai transmis la valeur de la clé au paramètre de la clé, et non au paramètre du compartiment. »

C'est une préoccupation valable et elle devrait être couverte par un test quelque part . L'approche de test que je préconise ici ne vérifie pas que vous avez appelé le client Minio avec le compartiment et les paramètres clés dans le bon ordre.

Une excellente citation que j'ai lue récemment disait : « La moquerie introduit des hypothèses, ce qui introduit des risques [10] ». Vous supposez que la bibliothèque cliente est correctement implémentée, vous supposez que toutes les limites sont solides, vous supposez que vous savez comment la bibliothèque se comporte réellement.

Se moquer de la bibliothèque ne fait que se moquer des hypothèses et rend vos tests plus fragiles et sujets à changement lorsque vous mettez à jour le code (c'est ce que Martin Fowler a conclu dans Mocks Aren't Stubs [3]). Lorsque le caoutchouc rencontre la route, nous devrons vérifier que nous utilisons correctement le client Minio et cela signifie des tests d'intégration (ceux-ci peuvent vivre dans une configuration Docker ou un environnement de test). Parce que nous aurons à la fois des tests unitaires et d'intégration, il n'est pas nécessaire qu'un test unitaire couvre l'implémentation exacte car le test d'intégration couvrira cela.

Dans notre exemple, les tests unitaires guident la conception de notre code et nous permettent de tester rapidement que les erreurs et les flux logiques fonctionnent comme prévu, en faisant exactement ce qu'ils doivent faire.
Pour certains, ils estiment que ce n'est pas une couverture de test unitaire suffisante. Ils s'inquiètent des points ci-dessus. Certains insisteront sur les interfaces de style poupée russe où une interface renvoie une autre interface qui renvoie une autre interface, peut-être comme celle-ci :

Et ensuite, ils peuvent extraire chaque partie du client Minio dans chaque wrapper, puis utiliser un générateur de simulation (ajoutant des dépendances aux builds et aux tests, augmentant les hypothèses et rendant les choses plus fragiles). A la fin, le mockist pourra dire quelque chose comme :

myClientMock.ExpectsCall("GetObject").Returns(mockObject).NumberOfCalls(1).WithArgs(key, bucket) - et c'est si vous pouvez vous rappeler l'incantation correcte pour ce DSL spécifique.

Cela représenterait beaucoup d'abstraction supplémentaire directement liée au choix d'implémentation d'utiliser le client Minio. Cela entraînera des tests fragiles lorsque nous découvrirons que nous devons modifier nos hypothèses sur le client ou que nous avons besoin d'un client entièrement différent.

Cela ajoute au temps de développement de code de bout en bout maintenant et à l'avenir, ajoute à la complexité du code et réduit la lisibilité, augmente potentiellement les dépendances vis-à-vis des générateurs fictifs et nous donne la valeur supplémentaire douteuse de savoir si nous avons mélangé le seau et les paramètres clés. dont nous aurions découvert dans les tests d'intégration de toute façon.

Au fur et à mesure que de plus en plus d'objets sont introduits, le couplage devient de plus en plus serré. Nous aurions peut-être fait une simulation d'enregistreur et plus tard, nous commencerons à avoir une simulation de métriques. Avant de vous en rendre compte, vous ajoutez une entrée de journal ou une nouvelle métrique et vous venez de casser d'innombrables tests qui ne s'attendaient pas à ce qu'une métrique supplémentaire passe.

La dernière fois que j'ai été surpris par cela dans Go, le cadre moqueur ne m'a même pas dit quel test ou fichier échouait car il a paniqué et est mort d'une mort horrible parce qu'il est tombé sur une nouvelle métrique (cela nécessitait une recherche binaire dans les tests en les commentant pour pouvoir trouver où nous devions modifier le comportement fictif). Les simulations peuvent-elles ajouter de la valeur ? Sûr. Est-ce que ça vaut le coût? Dans la plupart des cas, je ne suis pas convaincu.

Interfaces : simplicité et tests unitaires pour gagner

Nous avons montré que nous pouvons guider la conception et garantir que le code et les chemins d'erreur appropriés sont suivis avec une utilisation simple des interfaces dans Go. En écrivant de simples faux qui adhèrent aux interfaces, nous pouvons voir que nous n'avons pas besoin de mocks, de frameworks de mocking ou de générateurs de mock pour créer du code conçu pour les tests. Nous avons également noté que les tests unitaires ne sont pas tout et que vous devez écrire des tests d'intégration pour vous assurer que les systèmes sont correctement intégrés les uns aux autres.

J'espère obtenir un article sur des moyens astucieux d'exécuter des tests d'intégration à l'avenir ; restez à l'écoute!

Les références

1: Endo-Testing: Unit Testing with Mock Objects (2000): Voir l'introduction pour la définition de l'objet simulé
2 : Le petit moqueur : voir la partie sur les contrefaçons, en particulier, "un faux a un comportement commercial. Vous pouvez conduire un faux à se comporter de différentes manières en lui donnant des données différentes.
3 : Les simulations ne sont pas des stubs : consultez la section "Alors, devrais-je être un classique ou un mockiste ?" Martin Fowler déclare : "Je ne vois aucun avantage convaincant pour le mockist TDD et je suis préoccupé par les conséquences du couplage des tests à la mise en œuvre."
4 : Approche naïve : une version simplifiée du code. Voir [7].
5 : https://go-proverbs.github.io/ : La liste des Go Proverbs avec des liens vers des talks.
6 : https://www.youtube.com/watch?v=PAAkCSZUG1c&t=5m17s : lien direct vers l'exposé de Rob Pike concernant la taille et l'abstraction de l'interface.
7 : Version complète du code de démonstration : vous pouvez cloner le référentiel et exécuter "go test".
8 : Tests pilotés par table : une stratégie de test pour organiser le code de test afin de réduire la duplication.
9 : Tests pour la version complète du code de démonstration. Vous pouvez les exécuter avec `go test`.
10 : Questions à se poser lors de la rédaction de tests par Michal Charemza : La moquerie introduit des hypothèses, et les hypothèses introduisent des risques.