Où est la limite entre la logique d'application des tests unitaires et les constructions de langage méfiantes?

87

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 Storea 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 forEachstructure de langage intégrée ?

Nous pourrions, bien sûr, passer en faux dataStoreet 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 forEachpar une forboucle 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, panet 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' bakeCookiesexemple, 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.

Jonas
la source
44
Après ça court. Avez-vous des cookies
Ewan
6
en ce qui concerne votre mise à jour: pourquoi voudriez-vous jamais vous moquer d'une casserole? ou de la pâte? cela ressemble à de simples objets en mémoire qui devraient être faciles à créer, et il n’ya donc aucune raison pour que vous ne les testiez pas comme une seule unité. Rappelez-vous que "l'unité" dans "tests unitaires" ne signifie pas "une seule classe". cela signifie "la plus petite unité de code possible utilisée pour accomplir quelque chose". une casserole n'est probablement rien de plus qu'un conteneur pour les objets en pâte, alors il serait pratique de la tester de manière isolée plutôt que de simplement tester la méthode bakeCookies de l'extérieur.
sara
11
En fin de compte, le principe de base est que vous écrivez suffisamment de tests pour vous assurer que le code fonctionne et qu'il s'agit d'un "canari dans la mine" adéquat lorsque quelqu'un modifie quelque chose. C'est ça. Il n'y a pas d'incantations magiques, de suppositions formules ou d'assertions dogmatiques, ce qui explique pourquoi une couverture de code de 85% à 90% (et non de 100%) est généralement considérée comme excellente.
Robert Harvey
5
@RobertHarvey malheureusement, les platitudes stéréotypées et les extraits sonores de TDD, bien que vous méritant l'entente enthousiaste d'un accord, ne contribuent pas à résoudre les problèmes du monde réel. pour cela, vous devez vous salir les mains et risquer de répondre à une question réelle
Jonah
4
Test unitaire par ordre décroissant de complexité cyclomatique. Croyez-moi, vous allez manquer de temps avant d'arriver à cette fonction
Neil McGuigan

Réponses:

118

Devrait savePeople()être testé en unité? Oui. Vous ne testez pas que cela dataStore.savePersonfonctionne, ou que la connexion à la base de données fonctionne, ou même que cela foreachfonctionne. Vous testez que vous savePeopleremplissez 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 forEachpartie de l'implémentation afin qu'elle enregistre toujours uniquement le premier élément. Voulez-vous pas un test unitaire pour attraper cela?

Bryan Oakley
la source
20
@RobertHarvey: Il y a beaucoup de zones grises et faire la distinction, IMO, n'a pas d'importance. Vous avez raison cependant - il n'est pas vraiment important de tester que "cela appelle les bonnes fonctions" mais plutôt "qu'il fait la bonne chose" quelle que soit la façon dont il le fait. Ce qui est important, c’est de tester cela, étant donné un ensemble spécifique d’entrées pour la fonction, vous obtenez un ensemble spécifique de sorties. Cependant, je peux voir en quoi cette dernière phrase peut être source de confusion, alors je l’ai enlevée.
Bryan Oakley
64
"Vous testez que savePeople respecte la promesse qu'il a faite dans le cadre de son contrat." Cette. Tellement ça.
Lovis
2
Sauf si vous avez un test système "de bout en bout" qui le couvre.
Ian
6
@Ian Les tests de bout en bout ne remplacent pas les tests unitaires, ils sont complémentaires. Le fait que vous ayez un test de bout en bout qui vous assure de sauvegarder une liste de personnes ne signifie pas que vous ne devriez pas avoir un test unitaire pour le couvrir également.
Vincent Savard
4
@VincentSavard, mais le coût / bénéfice d'un test unitaire est réduit si le risque est contrôlé d'une autre manière.
Ian
36

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 savePeopleaprès l'avoir implémentée savePerson. Les fonctions savePeopleet savePersondé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 de savePeoplesavoir quelle devrait être la fonction - que ce soit une fonction libre ou une partie de la dataStore.

En fin de compte , les tests ne seraient pas seulement vérifier si vous pouvez correctement enregistrer un Personen Store, 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 la savePeoplefonction 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’une forEachou 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 existants savePersonpour exécuter la nouvelle fonction savePeople, 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.

MichelHenrich
la source
4
Essentiellement, testez l'interface et non l'implémentation.
Snoop
8
Points justes et perspicaces. Pourtant, je pense que ma vraie question est en train d'être esquivée :) Votre réponse est la suivante: "Dans le monde réel, dans un système bien conçu, je ne pense pas que cette version simplifiée de votre problème existerait." Encore une fois, c'est juste, mais j'ai spécifiquement créé cette version simplifiée pour souligner l'essence d'un problème plus général. Si vous ne pouvez pas aller au-delà de la nature artificielle de l'exemple, vous pouvez peut-être imaginer un autre exemple dans lequel vous auriez une bonne raison pour une fonction similaire, qui se limitait à l'itération et à la délégation. Ou peut-être pensez-vous que c'est tout simplement impossible?
Jonah
@Jonah mis à jour. J'espère que cela répond un peu mieux à votre question. Tout cela est très basé sur l'opinion et peut être contraire à l'objectif de ce site, mais c'est certainement une discussion très intéressante à avoir. A propos, j’ai essayé de répondre du point de vue du travail professionnel, où nous devons nous efforcer de laisser des tests unitaires pour tous les comportements d’application, quelle que soit la trivialité de la mise en œuvre, car nous avons le devoir de construire système documenté pour les nouveaux responsables si nous partons. Pour des projets personnels ou, disons, non critiques (l'argent est également critique), j'ai une opinion très différente.
MichelHenrich
Merci pour la mise à jour. Comment voulez-vous tester savePeople? Comme je l'ai décrit dans le dernier paragraphe de OP ou d'une autre manière?
Jonah
1
Désolé, je ne me suis pas fait comprendre avec la partie "no mocks impliqués". Je voulais dire que je n'utiliserais pas une maquette pour la savePersonfonction que vous avez suggérée, mais que je la testerais avec la méthode plus générale savePeople. Les tests unitaires pour Storeseraient modifiés pour s'exécuter savePeopleplutôt que d'appeler directement savePerson, 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.
MichelHenrich
21

SavePeople () doit-il être testé par unité

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:

function testSavePeople() {
    myDataStore = new Store('some connection string', 'password');
    myPeople = ['Joe', 'Maggie', 'John'];
    savePeople(myDataStore, myPeople);
    assert(myDataStore.containsPerson('Joe'));
    assert(myDataStore.containsPerson('Maggie'));
    assert(myDataStore.containsPerson('John'));
}

Ce test fait plusieurs choses:

  • il vérifie le contrat de la fonction savePeople()
  • il ne se soucie pas de la mise en œuvre de savePeople()
  • il documente l'exemple d'utilisation de 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émentation savePeople()à utiliser saveBulkPerson()n'interromprait pas le test unitaire tant qu'elle saveBulkPerson()fonctionnerait comme prévu. Et si d'une saveBulkPerson()manière ou d'une autre ne fonctionne pas comme prévu, votre test unitaire le détectera.

ou de tels tests équivaudraient-ils à tester la construction de langage forEach intégrée?

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é doughconvient panou affirme que le doughest épuisé. Vérifiez que le pancontient des cookies après l'appel de la fonction. Assure que le ovenest 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 ovenn'est pas vide avant l'appel? Que se passe-t-il s'il n'y en a pas assez dough? Si le panest 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:

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);
}

Pour une fonction comme celle-ci, je me moquerais de / stub / fake (tout ce qui semble plus général) des paramètres dataStoreet emailService. 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:

  • il a inséré un utilisateur dans le magasin de données
  • il a envoyé (ou au moins appelé la méthode correspondante) un email de bienvenue
  • il a enregistré l'adresse IP des utilisateurs dans le magasin de données
  • il a délégué toute exception / erreur rencontrée (le cas échéant)

Les 3 premières vérifications peuvent être effectuées avec des faux, des moignons ou des faux ( dataStoreet emailServicevous 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:

  • Un faux est un objet qui se comporte de la même manière que l'original et qui, dans une certaine mesure, est indiscernable. Son code peut normalement être réutilisé à travers les tests. Cela peut, par exemple, être une simple base de données en mémoire pour un wrapper de base de données.
  • Un tronçon implémente simplement autant que nécessaire pour effectuer les opérations requises de ce test. Dans la plupart des cas, un talon est spécifique à un test ou à un groupe de tests ne nécessitant qu'un petit ensemble des méthodes de l'original. Dans cet exemple, il peut s'agir d'une application dataStorequi implémente simplement une version appropriée de insertUserRecord()and recordIpAddress().
  • Un mock est un objet qui vous permet de vérifier son utilisation (le plus souvent en vous permettant d'évaluer les appels à ses méthodes). J'essayerais de les utiliser avec parcimonie dans les tests unitaires car en les utilisant, vous essayez en fait de tester la mise en œuvre de la fonction et non l'adhérence de son interface, mais ils ont toujours leurs utilisations. Il existe de nombreux cadres de simulation pour vous aider à créer exactement le modèle dont vous avez besoin.

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, cela 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.

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.

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.

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:

  • Si vous moquez l'objet du four, il n'attendra pas cet appel et échouera au test, bien que le comportement observable de la méthode n'ait pas changé (vous disposez toujours d'un pan de cookies, espérons-le).
  • Un talon peut échouer ou non, selon que vous avez uniquement ajouté les méthodes à tester ou l'interface entière avec certaines méthodes factices.
  • Un faux ne devrait pas échouer, car il devrait implémenter la méthode (en fonction de l'interface)

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).

hoffmale
la source
1
Le problème est que dès que vous écrivez, 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.
Jonah
Je suppose que je peux compter sur un magasin de données de test (peu importe que ce soit réel ou fictif) et que tout fonctionne comme prévu (étant donné que je devrais déjà avoir des tests unitaires pour ces cas). Le test vise uniquement à 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.
hoffmale
Pour clarifier, si vous utilisez une maquette, tout ce que vous pouvez faire est d’affirmer qu’une méthode sur cette maquette a été appelée , avec peut-être un paramètre spécifique. Vous ne pouvez pas affirmer l'état de la maquette par la suite. Donc, si vous voulez faire des assertions sur l’état de la base de données après avoir appelé la méthode testée 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.
Jonah
1
il ne doit pas nécessairement s'agir d'une véritable base de données, mais simplement d'un objet implémentant la même interface que le magasin de données réel (read: il passe les tests unitaires correspondants pour l'interface du magasin de données). Je considère toujours cela comme une moquette. Laissez la maquette stocker tout ce qui est ajouté par n'importe quelle méthode dans un tableau et vérifiez si les données de test (éléments de 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.
hoffmale
"Laissez la maquette stocker tout ce qui est ajouté par n'importe quelle méthode dans un tableau et vérifiez si les données de test (éléments de myPeople) sont dans le tableau" - c'est toujours une "vraie" base de données, juste une base ad hoc, en mémoire celui que vous avez construit. "À mon humble avis, un simulacre devrait toujours avoir le même comportement observable que celui attendu d'un objet réel" - je suppose que vous pouvez le défendre, mais ce n'est pas ce que "simulacre" signifie dans la littérature sur les tests ou dans l'une des bibliothèques populaires. J'ai vu. Une simulation vérifie simplement que les méthodes attendues sont appelées avec les paramètres attendus.
Jonah
13

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 savePeoplefonctionne, 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é.

Brandon
la source
Le refactoring en vrac est un bon exemple. Le test unitaire possible que j'ai suggéré dans le PO - qu'un modèle de dataStore l'ait savePersonappelé 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?
Jonah
1
@ jpmc26 Qu'en est-il d'un test qui vérifie que les personnes ont été sauvées ...?
user253751
1
@immibis Je ne comprends pas ce que cela signifie. Vraisemblablement, le vrai magasin est sauvegardé par une base de données, vous devrez donc vous moquer de lui ou le remplacer pour un test unitaire. Donc, à ce moment-là, vous allez tester que votre maquette ou votre talon peut stocker des objets. C'est totalement inutile. Le mieux que vous puissiez faire est d’affirmer que la savePersonmé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.)
Jpmc26
1
@immibis Je ne considère pas cela comme un test utile. L'utilisation du faux magasin de données ne me donne aucune confiance que cela fonctionnera avec la réalité. Comment savoir si mes faux fonctionnent comme des vrais? Je préférerais simplement laisser une suite de tests d'intégration le couvrir. (Je devrais probablement préciser que je parlais de "n'importe quel test unitaire " dans mon premier commentaire ici.)
jpmc26
1
@immibis Je suis en train de reconsidérer ma position. J'ai été sceptique quant à la valeur des tests unitaires à cause de problèmes de ce type, mais je le sous-estime peut-être même si vous simulez une entrée. Je ne sais que les tests d' entrée / sortie a tendance à être beaucoup plus utile que les tests lourds simulés, mais peut - être un refus de remplacer une entrée avec un faux est en fait une partie du problème ici.
jpmc26
6

Devrait bakeCookies()être testé? Oui.

Comment une fonction comme celle-ci devrait-elle être testée par unité, en supposant que cela soit le cas? Il m'est difficile d'imaginer un type de test unitaire qui ne se contente pas de simuler la pâte, la casserole et le four, puis d'affirmer que des méthodes sont utilisées.

Pas vraiment. Examinez attentivement CE QUE la fonction est censée faire - elle est censée définir l' ovenobjet dans un état spécifique. En regardant le code, il apparaît que les états des objets panand doughimportent peu. Donc, vous devriez passer un ovenobjet (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:

  1. 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.

  2. 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.

bûcheron
la source
Salut, merci pour votre réponse. Cela vous dérangerait-il de regarder ma deuxième mise à jour et de donner votre opinion sur la manière de tester un peu la fonction dans cet exemple?
Jonah
Je trouve que cela peut être efficace lorsque vous pouvez utiliser un vrai four ou un faux four, mais est nettement moins efficace avec un four factice. Les imitations (selon les définitions de Meszaros) n'ont pas d'état, à part l'enregistrement des méthodes qui leur ont été appelées. D'après mon expérience, lorsque des fonctions similaires bakeCookiessont 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.
James_pic
@ James_pic, exactement. Et oui, c'est la définition fictive que j'utilise. Alors, étant donné votre commentaire, que faites-vous dans un cas comme celui-ci? Renoncer à l'examen? Écrire le test de répétition d'implémentation fragile quand même? Autre chose?
Jonah
@Jonah Je vais généralement soit essayer de tester ce composant avec des tests d'intégration (j'ai trouvé que les avertissements sur le fait qu'il était plus difficile de déboguer étaient exagérés, peut-être en raison de la qualité des outils modernes), ou bien prendre la peine de créer un faux semi-convaincant.
James_pic
3

SavePeople () doit-il être testé par unité, ou de tels tests équivaudraient-ils à tester la construction de langage forEach intégrée?

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.

Telastyn
la source
Oui, je conviens que des conditions d'erreur subtiles doivent être testées, mais si ce n'est pas une question intéressante, la réponse est claire. D'où la raison pour laquelle j'ai spécifiquement déclaré que, pour les besoins de ma question, savePeoplene devrait pas être responsable du traitement des erreurs. Pour clarifier encore une fois, en supposant que ce savePeoplesoit 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?
Jonah
@ Jonah: Si vous voulez insister pour que votre test unitaire soit limité à la foreachconstruction, 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.
Robert Harvey
1
@ jonah - devrait-il parcourir et économiser autant que possible ou s'arrêter par erreur? La sauvegarde simple ne peut pas en décider, car elle ne sait pas comment elle est utilisée.
Telastyn
1
@jonah - bienvenue sur le site. L'un des éléments clés de notre format de questions-réponses est que nous ne sommes pas là pour vous aider . Votre question vous aide bien sûr, mais elle aide également de nombreuses autres personnes qui visitent le site à la recherche de réponses à leurs questions. J'ai répondu à la question que vous avez posée. Ce n'est pas de ma faute si vous n'aimez pas la réponse ou si vous préférez déplacer les buts. Et franchement, il semble que les autres réponses disent la même chose de base, bien que de façon plus éloquente.
Telastyn
1
@Telastyn, j'essaie de mieux comprendre les tests unitaires. Ma question initiale n'était pas assez claire et j'ajoute donc des clarifications pour orienter la conversation sur ma vraie question. Vous choisissez d'interpréter cela comme une façon de vous tromper dans le jeu "avoir raison". J'ai passé des centaines d'heures à répondre à des questions sur la révision de code et les SO. Mon but est toujours d'aider les personnes à qui je réponds. Si le vôtre ne l'est pas, c'est votre choix. Vous n'êtes pas obligé de répondre à mes questions.
Jonah
3

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.

JeffO
la source
Merci pour vos commentaires. Bons points. La question à laquelle je veux vraiment une réponse (je viens d'ajouter une autre mise à jour à clarifier) ​​est la manière appropriée de tester des fonctions qui ne font rien d'autre que d'appeler une séquence d'autres services par délégation. Dans de tels cas, il semble que les tests unitaires appropriés pour "documenter le contrat" ​​ne soient qu'une reformulation de la mise en œuvre de la fonction, affirmant que les méthodes sont appelées de différentes manières. Pourtant, le test étant identique à la mise en œuvre dans ces cas, on se sent mal ...
Jonah
1

SavePeople () doit-il être testé par unité, ou de tels tests équivaudraient-ils à tester la construction de langage forEach intégrée?

@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).

utnapistim
la source
1

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

  • un nouvel enregistrement d'utilisateur doit être créé dans la base de données
  • un email de bienvenue doit être envoyé
  • l'adresse IP de l'utilisateur doit être enregistrée à des fins de fraude.

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...

var validatedUserData = new UserData(); //we can use the real object for this
var emailService = new MockEmailService(); //a simple mock which saves sentEmails to a List<string>
var dataStore = new MockDataStore(); //a simple mock which saves ips to a List<string>

//run the test
target.createNewUser(validatedUserData, emailService, dataStore);

//check the results
Assert.AreEqual(1, emailService.EmailsSent.Count());
Assert.AreEqual(1, dataStore.IpsRecorded.Count());
Assert.AreEqual(1, dataStore.UsersSaved.Count());

Cela sépare les détails de mise en œuvre de la méthode testée du comportement souhaité. Une autre implémentation:

function createNewUser(validatedUserData, emailService, dataStore) {
  userId = dataStore.bulkInsedrtUserRecords(new [] {validateduserData});
  emailService.addEmailToQueue(validatedUserData);
  emailService.ProcessQueue();
  dataStore.recordIpAddress(userId, validatedUserData.ip);
}

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.

Ewan
la source
ce n'est pas un test d'intégration simplement parce que vous mentionnez les noms de 2 classes concrètes ... Les tests d'intégration consistent à tester des intégrations avec des systèmes externes, tels que le disque IO, la base de données, les services Web externes et ainsi de suite. -mémoire, rapide, vérifie ce qui nous intéresse, etc. Je conviens que le renvoi de la méthode aux cookies donne directement l'impression d'être un meilleur design.
Sara
3
Attendez. Tout ce que nous savons, pan.getcookies envoie un courrier électronique à un cuisinier pour lui demander de retirer les biscuits du four lorsqu'il en a l'occasion
Ewan
Je suppose que c'est théoriquement possible, mais ce serait un nom assez trompeur. qui a jamais entendu parler de l'équipement du four qui a envoyé des emails? mais je vois que vous pointez, ça dépend. Je suppose que ces classes collaboratrices sont des objets feuille ou tout simplement des choses en mémoire, mais s’ils font des choses sournoises, la prudence est de mise. Je pense que l'envoi d'email devrait certainement être fait à un niveau supérieur à celui-ci. cela ressemble à la logique métier bas et sale dans les entités.
Sara
2
C'était une question rhétorique, mais: "qui a déjà entendu parler de l'équipement du four qui envoie des emails?" venturebeat.com/2016/03/08/…
clacke
Bonjour Ewan, je pense que cette réponse se rapproche le plus possible de ce que je demande réellement. Je pense que votre point de vue sur le bakeCookiesretour 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.
Jonah
0

Vous devriez également tester bakeCookies- ce qui e / g devrait / devrait bakeCookies(egg, pan, oven)donner? Un œuf sur le plat ou une exception? À eux seuls, ils panne ovense soucient pas des ingrédients eux-mêmes, puisqu’aucun d’entre eux n’est censé le faire, mais bakeCookiesdevrait normalement donner des biscuits. De manière plus générale , il peut dépendre de la façon dont doughest obtenue et s'il est une chance qu'il devienne simple eggou par exemple au waterlieu.

Tobias Kienzler
la source