Considérons une fonction comme celle-ci:
function savePeople(dataStore, people) {
people.forEach(person => dataStore.savePerson(person));
}
Cela pourrait être utilisé comme ceci:
myDataStore = new Store('some connection string', 'password');
myPeople = ['Joe', 'Maggie', 'John'];
savePeople(myDataStore, myPeople);
Laissez - nous supposer que Store
a ses propres tests unitaires ou est fourni par le fournisseur. En tout cas, nous avons confiance Store
. Et supposons en outre que la gestion des erreurs - par exemple, des erreurs de déconnexion de base de données - n’est pas de la responsabilité de savePeople
. En effet, supposons que le magasin lui - même est une base de données magique qui ne peut en aucun cas se tromper. Compte tenu de ces hypothèses, la question est la suivante:
Devraient savePeople()
être testés à l'unité, ou de tels tests équivaudraient-ils à tester la forEach
structure de langage intégrée ?
Nous pourrions, bien sûr, passer en faux dataStore
et affirmer que l’ dataStore.savePerson()
on appelle une fois pour chaque personne. Vous pouvez certainement faire valoir qu'un tel test offre une sécurité contre les modifications d'implémentation: par exemple, si nous décidons de remplacer forEach
par une for
boucle traditionnelle ou par une autre méthode d'itération. Donc, le test n'est pas entièrement trivial. Et pourtant, cela semble terriblement proche ...
Voici un autre exemple qui pourrait être plus fructueux. Considérons une fonction qui ne fait que coordonner d'autres objets ou fonctions. Par exemple:
function bakeCookies(dough, pan, oven) {
panWithRawCookies = pan.add(dough);
oven.addPan(panWithRawCookies);
oven.bakeCookies();
oven.removePan();
}
Comment une fonction comme celle-ci devrait-elle être testée par unité, à supposer que vous pensiez qu'elle le devrait? Il est difficile pour moi d'imaginer tout type de test unitaire qui ne se moque pas seulement dough
, pan
et oven
, puis affirment que les méthodes sont appelées sur eux. Mais un tel test ne fait que dupliquer la mise en oeuvre exacte de la fonction.
Cette incapacité à tester la fonction de manière explicite dans une boîte noire indique-t-elle un défaut de conception avec la fonction elle-même? Si oui, comment pourrait-il être amélioré?
Pour clarifier encore la question qui motive l' bakeCookies
exemple, je vais ajouter un scénario plus réaliste, celui que j'ai rencontré lors de la tentative d'ajout de tests et de refactorisation du code hérité.
Lorsqu'un utilisateur crée un nouveau compte, un certain nombre de choses doivent se produire: 1) un nouvel enregistrement d'utilisateur doit être créé dans la base de données 2) un courrier électronique de bienvenue doit être envoyé 3) l'adresse IP de l'utilisateur doit être enregistrée pour fraude fins.
Nous voulons donc créer une méthode qui relie toutes les étapes du "nouvel utilisateur":
function createNewUser(validatedUserData, emailService, dataStore) {
userId = dataStore.insertUserRecord(validateduserData);
emailService.sendWelcomeEmail(validatedUserData);
dataStore.recordIpAddress(userId, validatedUserData.ip);
}
Notez que si l'une de ces méthodes génère une erreur, nous voulons que l'erreur apparaisse jusqu'au code appelant, de sorte qu'elle puisse la gérer comme elle l'entend. S'il est appelé par le code de l'API, il peut traduire l'erreur en un code de réponse http approprié. Si elle est appelée par une interface Web, l'erreur peut être traduite en un message approprié à afficher pour l'utilisateur, etc. Le fait est que cette fonction ne sait pas comment gérer les erreurs qui peuvent être générées.
L'essence de ma confusion est que, pour tester un peu une telle fonction, il semble nécessaire de répéter la mise en œuvre exacte dans le test lui-même (en précisant que les méthodes sont appelées dans un certain ordre), ce qui semble faux.
la source
Réponses:
Devrait
savePeople()
être testé en unité? Oui. Vous ne testez pas que celadataStore.savePerson
fonctionne, ou que la connexion à la base de données fonctionne, ou même que celaforeach
fonctionne. Vous testez que voussavePeople
remplissez la promesse faite dans le cadre de votre contrat.Imaginez ce scénario: quelqu'un fait un grand refactor de la base de code et supprime accidentellement la
forEach
partie de l'implémentation afin qu'elle enregistre toujours uniquement le premier élément. Voulez-vous pas un test unitaire pour attraper cela?la source
Habituellement, ce genre de question se pose lorsque les gens font du développement "après test". Abordez ce problème du point de vue de TDD, où les tests viennent avant l’implémentation, et posez-vous à nouveau cette question à titre d’exercice.
Au moins dans mon application de TDD, qui est généralement de l'extérieur, je ne mettrais pas en œuvre une fonction comme
savePeople
après l'avoir implémentéesavePerson
. Les fonctionssavePeople
etsavePerson
démarrent comme une seule et sont testées à partir des mêmes tests unitaires; la séparation entre les deux se produirait après quelques tests, dans l'étape de refactoring. Ce mode de travail poserait également la question desavePeople
savoir quelle devrait être la fonction - que ce soit une fonction libre ou une partie de ladataStore
.En fin de compte , les tests ne seraient pas seulement vérifier si vous pouvez correctement enregistrer un
Person
enStore
, mais aussi beaucoup de personnes. Cela me conduirait également à me demander si d'autres vérifications sont nécessaires, par exemple, "Dois-je m'assurer que lasavePeople
fonction est atomique, en sauvegardant tout ou rien?", "Peut-il simplement renvoyer des erreurs aux personnes qui ne pourraient pas ' t être sauvé? Comment ressembleraient ces erreurs? ", et ainsi de suite. Tout cela représente bien plus que la simple vérification de l’utilisation d’uneforEach
ou d’autres formes d’itération.Cependant, si l'exigence de sauver plusieurs personnes à la fois ne s'imposait qu'après avoir
savePerson
été livrée, je mettrais alors à jour les tests existantssavePerson
pour exécuter la nouvelle fonctionsavePeople
, en veillant à ce qu'elle puisse toujours sauver une personne en déléguant simplement au début, testez ensuite le comportement de plus d'une personne à travers de nouveaux tests, en vous demandant s'il serait nécessaire de rendre le comportement atomique ou non.la source
savePeople
? Comme je l'ai décrit dans le dernier paragraphe de OP ou d'une autre manière?savePerson
fonction que vous avez suggérée, mais que je la testerais avec la méthode plus généralesavePeople
. Les tests unitaires pourStore
seraient modifiés pour s'exécutersavePeople
plutôt que d'appeler directementsavePerson
, donc pour cela, aucune simulation n'est utilisée. Mais la base de données ne devrait bien sûr pas être présente, car nous aimerions isoler les problèmes de codage des divers problèmes d’intégration rencontrés avec les bases de données réelles. Nous avons donc encore une simulation.Oui, ça devrait. Mais essayez d’écrire vos conditions de test d’une manière indépendante de la mise en oeuvre. Par exemple, convertissez votre exemple d'utilisation en test unitaire:
Ce test fait plusieurs choses:
savePeople()
savePeople()
savePeople()
Prenez note que vous pouvez toujours simuler / tronquer / simuler le magasin de données. Dans ce cas, je ne vérifierais pas les appels de fonction explicites, mais le résultat de l'opération. De cette façon, mon test est préparé pour les modifications / refacteurs futurs.
Par exemple, votre implémentation de magasin de données pourrait fournir une
saveBulkPerson()
méthode dans le futur. Désormais, une modification de l'implémentationsavePeople()
à utilisersaveBulkPerson()
n'interromprait pas le test unitaire tant qu'ellesaveBulkPerson()
fonctionnerait comme prévu. Et si d'unesaveBulkPerson()
manière ou d'une autre ne fonctionne pas comme prévu, votre test unitaire le détectera.Comme cela a été dit, essayez de tester les résultats attendus et l'interface de fonction, mais pas d'implémentation (à moins que vous ne fassiez des tests d'intégration - il pourrait alors être utile de capturer des appels de fonction spécifiques). S'il existe plusieurs façons d'implémenter une fonction, elles doivent toutes fonctionner avec votre test unitaire.
En ce qui concerne votre mise à jour de la question:
Testez les changements d'état! Par exemple, une partie de la pâte sera utilisée. Selon votre implémentation, affirmez que la quantité d’utilisé
dough
convientpan
ou affirme que ledough
est épuisé. Vérifiez que lepan
contient des cookies après l'appel de la fonction. Assure que leoven
est vide / dans le même état que précédemment.Pour des tests supplémentaires, vérifiez les cas de bord: Que se passe-t-il si le
oven
n'est pas vide avant l'appel? Que se passe-t-il s'il n'y en a pas assezdough
? Si lepan
est déjà plein?Vous devriez pouvoir déduire toutes les données requises pour ces tests à partir des objets de pâte, de casserole et de four eux-mêmes. Pas besoin de capturer les appels de fonction. Traitez la fonction comme si sa mise en œuvre ne serait pas disponible!
En fait, la plupart des utilisateurs de TDD écrivent leurs tests avant d’écrire la fonction, ils ne dépendent donc pas de la mise en oeuvre réelle.
Pour votre dernier ajout:
Pour une fonction comme celle-ci, je me moquerais de / stub / fake (tout ce qui semble plus général) des paramètres
dataStore
etemailService
. Cette fonction ne fait aucune transition d'état sur aucun paramètre, elle les délègue aux méthodes de certains d'entre eux. Je voudrais essayer de vérifier que l'appel à la fonction a fait 4 choses:Les 3 premières vérifications peuvent être effectuées avec des faux, des moignons ou des faux (
dataStore
etemailService
vous ne voulez vraiment pas envoyer de courriels lors des tests). Depuis que j'ai dû chercher ceci pour certains commentaires, ce sont les différences:dataStore
qui implémente simplement une version appropriée deinsertUserRecord()
andrecordIpAddress()
.Les exceptions / erreurs attendues sont des cas de test valides: vous confirmez que, si un tel événement se produit, la fonction se comporte comme prévu. Cela peut être réalisé en laissant l'objet simulé / faux / tronqué jeté lorsque vous le souhaitez.
Parfois, cela doit être fait (même si cela vous tient particulièrement à cœur lors des tests d'intégration). Le plus souvent, il existe d'autres moyens de vérifier les effets secondaires / changements d'état attendus.
La vérification des appels de fonctions exactes donne lieu à des tests unitaires plutôt fragiles: seuls de petits changements apportés à la fonction d'origine entraînent leur échec. Cela peut être souhaité ou non, mais cela nécessite une modification du ou des tests unitaires correspondants chaque fois que vous modifiez une fonction (que ce soit le refactoring, l'optimisation, la correction de bugs, ...).
Malheureusement, dans ce cas, le test unitaire perd une partie de sa crédibilité: puisqu'il a été modifié, il ne confirme pas la fonction après le changement se comporte de la même manière qu'auparavant.
Par exemple, imaginons que quelqu'un ajoute un appel à
oven.preheat()
(optimisation!) Dans votre exemple de préparation de biscuits:Dans mes tests unitaires, j'essaie d'être aussi général que possible: si la mise en œuvre change, mais que le comportement visible (du point de vue de l'appelant) est toujours le même, mes tests doivent réussir. Idéalement, le seul cas où je devrais modifier un test unitaire existant devrait être une correction de bogue (du test, pas de la fonction testée).
la source
myDataStore.containsPerson('Joe')
vous supposez l'existence d'une base de données de test fonctionnelle. Une fois que vous avez fait cela, vous écrivez un test d’intégration et non un test unitaire.savePeople()
ajouter ces personnes au magasin de données que vous fournissez, à condition que celui-ci implémente l'interface attendue. Un test d'intégration consisterait, par exemple, à vérifier que mon wrapper de base de données effectue les bons appels de base de données pour un appel de méthode.myDataStore.containsPerson('Joe')
, vous devez utiliser une base de données fonctionnelle. Une fois que vous avez franchi cette étape, ce n'est plus un test unitaire.myPeople
) sont dans le tableau. À mon humble avis, un simulacre devrait toujours avoir le même comportement observable que celui attendu d'un objet réel, sinon vous testez la conformité à l'interface simulacre, pas à l'interface réelle.La valeur principale d'un tel test est qu'il rend votre implémentation refactorable.
J'avais l'habitude de faire beaucoup d'optimisations de performances au cours de ma carrière et j'ai souvent trouvé des problèmes avec le modèle exact que vous avez démontré: pour enregistrer N entités dans la base de données, effectuez N insertions. Il est généralement plus efficace de faire une insertion en masse en utilisant une seule instruction.
D'autre part, nous ne voulons pas non plus optimiser prématurément. Si vous ne sauvegardez généralement que 1 à 3 personnes à la fois, l'écriture d'un lot optimisé risque d'être excessive.
Avec un test unitaire approprié, vous pouvez l'écrire de la manière dont vous l'avez implémenté ci-dessus. Si vous pensez que vous devez l'optimiser, vous êtes libre de le faire avec le filet de sécurité d'un test automatisé pour détecter les erreurs éventuelles. Naturellement, cela varie en fonction de la qualité des tests, testez donc généreusement et testez bien.
L’avantage secondaire du test unitaire de ce comportement est de servir de documentation pour en déterminer le but. Cet exemple trivial peut sembler évident, mais étant donné le point suivant, il pourrait être très important.
Le troisième avantage, souligné par d’autres, est que vous pouvez tester des détails sous la couverture qui sont très difficiles à tester avec des tests d’intégration ou de réception. Par exemple, si tous les utilisateurs doivent être enregistrés de manière atomique, vous pouvez écrire un scénario de test pour cela, ce qui vous permet de savoir qu'il se comporte comme prévu et sert également de documentation pour une exigence qui peut ne pas sembler évidente. aux nouveaux développeurs.
Je vais ajouter une pensée qui m'a été donné par un instructeur TDD. Ne testez pas la méthode. Testez le comportement. En d'autres termes, vous ne testez pas que cela
savePeople
fonctionne, vous testez que plusieurs utilisateurs peuvent être enregistrés en un seul appel.J'ai constaté que ma capacité à effectuer des tests unitaires de qualité et des TDD s’améliorait lorsque j’avais cessé de penser aux tests unitaires comme à la vérification du fonctionnement d’un programme, mais plutôt qu’ils vérifiaient qu’une unité de code répondait à mes attentes . Ce sont différents. Ils ne vérifient pas que cela fonctionne, mais ils vérifient que cela fait ce que je pense qu'il fait. Quand j'ai commencé à penser de cette façon, ma perspective a changé.
la source
savePerson
appelé pour chaque personne de la liste - romprait toutefois avec un refactoring en vrac des insertions. Ce qui pour moi indique que c'est un mauvais test unitaire. Cependant, je ne vois pas d'autre solution qui réussirait à la fois les implémentations en bloc et une sauvegarde par personne, sans utiliser une base de données de test réelle et en affirmer le contraire, ce qui semble faux. Pourriez-vous fournir un test qui fonctionne pour les deux implémentations?savePerson
méthode a été appelée pour chaque entrée, et si vous remplaciez la boucle par une insertion en bloc, vous n’appelleriez plus cette méthode. Donc, votre test se briserait. Si vous avez autre chose en tête, je suis ouvert à cela, mais je ne le vois pas encore. (Et ne pas voir que c'était mon point.)Devrait
bakeCookies()
être testé? Oui.Pas vraiment. Examinez attentivement CE QUE la fonction est censée faire - elle est censée définir l'
oven
objet dans un état spécifique. En regardant le code, il apparaît que les états des objetspan
anddough
importent peu. Donc, vous devriez passer unoven
objet (ou vous en moquer) et affirmer qu'il se trouve dans un état particulier à la fin de l'appel de la fonction.En d'autres termes, vous devriez affirmer que
bakeCookies()
cuit les cookies .Pour des fonctions très courtes, les tests unitaires peuvent sembler être un peu plus que de la tautologie. Mais n'oubliez pas que votre programme durera beaucoup plus longtemps que le temps que vous employez pour l'écrire. Cette fonction peut ou non changer dans le futur.
Les tests unitaires remplissent deux fonctions:
Il teste que tout fonctionne. C’est la fonction la moins utile des tests unitaires et il semble que vous semblez ne considérer cette fonctionnalité que lorsque vous posez la question.
Il vérifie que les futures modifications du programme ne suppriment pas les fonctionnalités précédemment implémentées. C'est la fonction la plus utile des tests unitaires et elle empêche l'introduction de bogues dans les gros programmes. C'est utile dans le codage normal lors de l'ajout de fonctionnalités au programme, mais plus utile dans le refactoring et les optimisations où les algorithmes centraux mettant en œuvre le programme sont modifiés de manière spectaculaire sans changer aucun comportement observable du programme.
Ne testez pas le code dans la fonction. Au lieu de cela, vérifiez que la fonction fait ce qu'elle dit. Lorsque vous examinez les tests unitaires de cette manière (fonctions de test, pas de code), vous réalisez que vous ne testez jamais les constructions de langage ni même la logique d'application. Vous testez une API.
la source
bakeCookies
sont testées de cette manière, elles ont tendance à se rompre lors des modifications, ce qui n'aurait pas d'incidence sur le comportement observable de l'application.Oui. Mais vous pourriez le faire d'une manière qui revérifierait la construction.
La chose à noter ici est comment cette fonction se comporte quand un
savePerson
échec à mi-parcours? Comment est-ce censé fonctionner?C’est le genre de comportement subtil que la fonction fournit que vous devez appliquer avec les tests unitaires.
la source
savePeople
ne devrait pas être responsable du traitement des erreurs. Pour clarifier encore une fois, en supposant que cesavePeople
soit uniquement responsable de parcourir la liste et de déléguer la sauvegarde de chaque élément à une autre méthode, faut-il quand même le tester?foreach
construction, et non à des conditions, des effets secondaires ou des comportements en dehors de celle-ci, vous avez raison; le nouveau test unitaire n’est vraiment pas très intéressant.La clé ici est votre point de vue sur une fonction particulière aussi triviale. La plupart de la programmation est triviale: attribuez une valeur, faites des calculs, prenez une décision: si cela vous suffit, continuez une boucle jusqu'à ... Isolément, tout est trivial. Vous venez de terminer les 5 premiers chapitres de tout livre enseignant un langage de programmation.
Le fait qu’il soit si facile de passer un test devrait être un signe que votre conception n’est pas si mauvaise. Préféreriez-vous un design difficile à tester?
"Cela ne changera jamais." Voici comment la plupart des projets échoués commencent. Un test d'unité détermine uniquement si l'unité fonctionne comme prévu dans certaines circonstances. Faites-le passer et vous pourrez alors oublier ses détails d'implémentation et simplement l'utiliser. Utilisez cet espace cérébral pour la tâche suivante.
Savoir que les choses fonctionnent comme prévu est très important et n’est pas anodin dans les grands projets et en particulier dans les grandes équipes. S'il y a une chose en commun entre les programmeurs, c'est que nous avons tous eu à faire face au code terrible de quelqu'un d'autre. Le moins que nous puissions faire est de passer des tests. En cas de doute, écrivez un test et passez à autre chose.
la source
@BryanOakley a déjà répondu à cette question, mais j'ai quelques arguments supplémentaires (je suppose):
Tout d'abord, un test unitaire sert à vérifier l'exécution d'un contrat et non la mise en œuvre d'une API. le test doit définir les conditions préalables, puis appeler, puis vérifier les effets, les effets secondaires, les invariants et les conditions de publication. Lorsque vous décidez quoi tester, l'implémentation de l'API n'a pas d'importance (et ne devrait pas l'être) .
Deuxièmement, votre test sera là pour vérifier les invariants lorsque la fonction changera . Le fait que cela ne change pas maintenant ne signifie pas que vous ne devriez pas avoir le test.
Troisièmement, il est utile d’avoir mis en œuvre un test trivial, à la fois dans une approche TDD (qui le prescrit) et en dehors de celle-ci.
Lors de l'écriture en C ++, pour mes classes, j'ai tendance à écrire un test trivial qui instancie un objet et vérifie les invariants (assignable, régulier, etc.). J'ai trouvé surprenant combien de fois ce test est rompu au cours du développement (en ajoutant, par exemple, un membre non amovible dans une classe, par erreur).
la source
Je pense que votre question se résume à:
Comment est-ce que je teste une fonction vide sans que ce soit un test d'intégration?
Si nous modifions votre fonction de cuisson des cookies pour renvoyer des cookies, par exemple, le test devient immédiatement évident.
Si nous devons appeler pan.GetCookies après avoir appelé la fonction, nous pouvons toutefois nous demander si c'est vraiment un test d'intégration ou si nous ne testons que l'objet pan?
Je pense que vous avez raison de dire que les tests unitaires avec tout simulé et la vérification des fonctions xy et z étaient appelées valeur insuffisante.
Mais! Je dirais que dans ce cas, vous devriez refactoriser vos fonctions void pour renvoyer un résultat testable OU utiliser des objets réels et faire un test d'intégration
--- Mise à jour pour l'exemple createNewUser
OK, cette fois le résultat de la fonction n'est pas facilement renvoyé. Nous voulons changer l'état des paramètres.
C'est là que je suis un peu controversé. Je crée des implémentations concrètes pour les paramètres stateful
S'il vous plaît, chers lecteurs, essayez de contrôler votre colère!
alors...
Cela sépare les détails de mise en œuvre de la méthode testée du comportement souhaité. Une autre implémentation:
Va toujours passer le test unitaire. De plus, vous avez l’avantage de pouvoir réutiliser les objets fictifs d’un test à l’autre, mais aussi de les injecter dans votre application pour les tests d’interface utilisateur ou d’intégration.
la source
bakeCookies
retour des cookies cuits au four est tout à fait approprié, et j’avais quelques idées après l’avoir posté. Je pense donc que ce n'est pas encore un bon exemple. J'ai ajouté une autre mise à jour qui, espérons-le, fournit un exemple plus réaliste de ce qui motive ma question. J'apprécierais votre contribution.Vous devriez également tester
bakeCookies
- ce qui e / g devrait / devraitbakeCookies(egg, pan, oven)
donner? Un œuf sur le plat ou une exception? À eux seuls, ilspan
neoven
se soucient pas des ingrédients eux-mêmes, puisqu’aucun d’entre eux n’est censé le faire, maisbakeCookies
devrait normalement donner des biscuits. De manière plus générale , il peut dépendre de la façon dontdough
est obtenue et s'il est une chance qu'il devienne simpleegg
ou par exemple auwater
lieu.la source