web-dev-qa-db-fra.com

Où est la frontière entre la logique d'application des tests unitaires et les constructions de langage méfiants?

Considérez une fonction comme celle-ci:

function savePeople(dataStore, people) {
    people.forEach(person => dataStore.savePerson(person));
}

Il pourrait être utilisé comme ceci:

myDataStore = new Store('some connection string', 'password');
myPeople = ['Joe', 'Maggie', 'John'];
savePeople(myDataStore, myPeople);

Supposons que Store possède ses propres tests unitaires ou soit fourni par le fournisseur. Dans tous les cas, nous faisons confiance à Store. Et supposons en outre que la gestion des erreurs - par exemple, des erreurs de déconnexion de la base de données - n'est pas 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 faire d'erreur. Étant donné ces hypothèses, la question est:

Est-ce que savePeople() devrait être testé unitaire, ou de tels tests reviendraient-ils à tester la construction du langage forEach intégré?

Nous pourrions, bien sûr, passer une maquette dataStore et affirmer que dataStore.savePerson() est appelé une fois pour chaque personne. Vous pourriez certainement faire valoir qu'un tel test offre une sécurité contre les changements d'implémentation: par exemple, si nous décidions de remplacer forEach par une boucle traditionnelle for, ou une autre méthode d'itération. Le test n'est donc pas entièrement trivial. Et pourtant, cela semble terriblement proche ...


Voici un autre exemple qui peut ê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 unitaire, en supposant que vous pensez qu'elle devrait l'être? Il m'est difficile d'imaginer tout type de test unitaire qui ne se moque pas simplement de dough, pan et oven, puis d'affirmer que des méthodes sont appelées sur eux. Mais un tel test ne fait rien d'autre que dupliquer l'implémentation exacte de la fonction.

Est-ce que cette incapacité à tester la fonction d'une manière significative en boîte noire indique un défaut de conception avec la fonction elle-même? Si oui, comment pourrait-il être amélioré?


Pour donner encore plus de clarté à la question motivant l'exemple bakeCookies, j'ajouterai un scénario plus réaliste, celui que j'ai rencontré en essayant d'ajouter des tests et de refactoriser le code hérité.

Lorsqu'un utilisateur crée un nouveau compte, un certain nombre de choses doivent se produire: 1) un nouvel enregistrement utilisateur doit être créé dans la base de données 2) un e-mail 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 se propage jusqu'au code appelant, afin qu'il puisse gérer l'erreur comme bon lui semble. S'il est appelé par le code API, il peut traduire l'erreur en un code de réponse http approprié. S'il est appelé par une interface Web, il peut traduire l'erreur en un message approprié à afficher à l'utilisateur, etc. Le fait est que cette fonction ne sait pas comment gérer les erreurs qui peuvent être levées.

L'essence de ma confusion est que pour tester une telle fonction, il semble nécessaire de répéter l'implémentation exacte dans le test lui-même (en spécifiant que les méthodes sont appelées sur les mocks dans un certain ordre) et cela semble faux.

88
Jonah

Est-ce que savePeople() doit être testé à l'unité? Oui. Vous ne testez pas cela dataStore.savePerson fonctionne, ou que la connexion db fonctionne, ou même que foreach fonctionne. Vous testez que savePeople remplit la promesse qu'il fait dans son contrat.

Imaginez ce scénario: quelqu'un fait un gros remaniement de la base de code et supprime accidentellement la partie forEach de l'implémentation afin qu'il n'enregistre toujours que le premier élément. Ne voudriez-vous pas qu'un test unitaire attrape cela?

117
Bryan Oakley

Habituellement, ce genre de question se pose lorsque les gens font un développement "test après". Abordez ce problème du point de vue de TDD, où les tests précèdent l'implémentation, et posez-vous à nouveau cette question comme exercice.

Au moins dans mon application de TDD, qui est généralement de l'extérieur vers l'intérieur, je n'implémenterais pas une fonction comme savePeople après avoir implémenté savePerson. Les fonctions savePeople et savePerson commenceraient comme une seule et seraient pilotées par les mêmes tests unitaires; la séparation entre les deux se ferait après quelques tests, dans l'étape de refactoring. Ce mode de travail poserait également la question de savoir où devrait être la fonction savePeople - qu'il s'agisse d'une fonction libre ou d'une partie de dataStore.

En fin de compte, les tests vérifieraient non seulement si vous pouvez correctement enregistrer un Person dans le Store, mais aussi de nombreuses personnes. Cela m'amènerait également à me demander si d'autres vérifications sont nécessaires, par exemple, "Dois-je m'assurer que la fonction savePeople est atomique, en enregistrant tout ou rien?", "Peut-il simplement renvoyer des erreurs pour les personnes qui n'ont pas pu être sauvées? À quoi ressembleraient ces erreurs? ", etc. Tout cela représente bien plus que la simple vérification de l'utilisation d'un forEach ou d'autres formes d'itération.

Cependant, si la nécessité d'enregistrer plus d'une personne à la fois ne venait qu'après la livraison de savePerson, je mettrais à jour les tests existants de savePerson pour exécuter la nouvelle fonction savePeople, en s'assurant qu'il peut encore sauver une personne en déléguant simplement au début, puis testez le comportement de plus d'une personne à travers de nouveaux tests, en pensant s'il serait nécessaire de rendre le comportement atomique ou non.

36
MichelHenrich

Faut-il que savePeople () soit testé unitaire

Oui, ça devrait. Mais essayez d'écrire vos conditions de test d'une manière indépendante de l'implémentation. Par exemple, transformer 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 l'implémentation de savePeople()
  • il documente l'exemple d'utilisation de savePeople()

Notez que vous pouvez toujours vous moquer/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 futurs changements/refactors.

Par exemple, l'implémentation de votre magasin de données pourrait fournir une méthode saveBulkPerson() à l'avenir - désormais, une modification de l'implémentation de savePeople() pour utiliser saveBulkPerson() ne briserait pas l'unité test aussi longtemps que saveBulkPerson() fonctionne comme prévu. Et si saveBulkPerson() ne fonctionne pas comme prévu, votre test unitaire will attrapera cela.

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

Comme dit, essayez de tester les résultats attendus et l'interface de fonction, pas pour l'implémentation (à moins que vous ne fassiez des tests d'intégration - alors intercepter des appels de fonction spécifiques pourrait être utile). S'il existe plusieurs façons d'implémenter une fonction, toutes devraient fonctionner avec votre test unitaire.

Concernant 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, assurez-vous que la quantité de dough utilisée correspond à pan ou assurez-vous que dough est épuisé. Affirmez que pan contient des cookies après l'appel de fonction. Affirmez que oven est vide/dans le même état qu'auparavant.

Pour des tests supplémentaires, vérifiez les cas Edge: que se passe-t-il si le oven n'est pas vide avant l'appel? Que se passe-t-il s'il n'y a pas assez de dough? Si le pan est déjà plein?

Vous devriez pouvoir déduire toutes les données requises pour ces tests 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 son implémentation n'était pas disponible pour vous!

En fait, la plupart des utilisateurs de TDD écrivent leurs tests avant d'écrire la fonction, ils ne dépendent donc pas de l'implémentation 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 utilisateur doit être créé dans la base de données 2) un e-mail 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 moque/stub/fake (ce qui semble plus général) les paramètres dataStore et emailService. Cette fonction ne fait aucune transition d'état sur aucun paramètre à elle seule, elle les délègue aux méthodes de certains d'entre eux. J'essaierais 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 du moins appelé la méthode correspondante) un e-mail de bienvenue
  • il a enregistré l'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 talons ou des faux de dataStore et emailService (vous ne voulez vraiment pas envoyer d'e-mails lors des tests). Étant donné que j'ai dû rechercher cela pour certains des commentaires, voici les différences:

  • Un faux est un objet qui se comporte de la même manière que l'original et est dans une certaine mesure indiscernable. Son code peut normalement être réutilisé entre 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 stub implémente juste autant que nécessaire pour accomplir 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 de méthodes de l'original. Dans cet exemple, il peut s'agir d'un dataStore qui implémente simplement une version appropriée de insertUserRecord() et recordIpAddress().
  • Une maquette est un objet qui vous permet de vérifier comment il est utilisé (le plus souvent en vous permettant d'évaluer les appels à ses méthodes). J'essaierais de les utiliser avec parcimonie dans les tests unitaires, car en les utilisant, vous essayez de tester l'implémentation de la fonction et non l'adhésion à son interface, mais ils ont toujours leurs utilisations. De nombreux frameworks simulés existent pour vous aider à créer exactement le simulateur dont vous avez besoin.

Notez que si l'une de ces méthodes génère une erreur, nous voulons que l'erreur se propage jusqu'au code appelant, afin qu'il puisse gérer l'erreur comme bon lui semble. S'il est appelé par le code API, il peut traduire l'erreur en un code de réponse HTTP approprié. S'il est appelé par une interface Web, il peut traduire l'erreur en un message approprié à afficher à l'utilisateur, etc. Le fait est que cette fonction ne sait pas comment gérer les erreurs qui peuvent être levé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 vous vous y attendez. Ceci peut être réalisé en laissant le faux objet/faux/talon correspondant lancer lorsque vous le souhaitez.

L'essence de ma confusion est que pour tester une telle fonction, il semble nécessaire de répéter l'implémentation exacte dans le test lui-même (en spécifiant que les méthodes sont appelées sur les mocks dans un certain ordre) et cela semble faux.

Parfois, cela doit être fait (même si vous vous souciez surtout de cela dans les 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 exacts rend les tests unitaires plutôt fragiles: seules de petites modifications de la fonction d'origine entraînent leur échec. Cela peut être souhaité ou non, mais cela nécessite une modification des tests unitaires correspondants chaque fois que vous modifiez une fonction (que ce soit la refactorisation, l'optimisation, la correction de bogues, ...).

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 que le changement se comporte de la même manière qu'auparavant.

Par exemple, considérons quelqu'un qui ajoute un appel à oven.preheat() (optimisation!) Dans votre exemple de cuisson de cookies:

  • Si vous vous êtes moqué de l'objet four, il ne s'attendra pas à cet appel et échouera au test, bien que le comportement observable de la méthode n'ait pas changé (vous avez toujours une casserole de cookies, espérons-le).
  • Un stub peut ou non échouer, selon que vous n'avez ajouté que les méthodes à tester ou l'ensemble de l'interface avec certaines méthodes factices.
  • Un faux ne devrait pas échouer, car il devrait implémenter la méthode (selon l'interface)

Dans mes tests unitaires, j'essaie d'être aussi général que possible: si l'implémentation 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 dont j'ai besoin pour modifier un test unitaire existant devrait être une correction de bogue (du test, pas de la fonction testée).

21
hoffmale

La principale valeur d'un tel test est qu'il rend votre implémentation refactorisable.

J'avais l'habitude de faire beaucoup d'optimisations de performances dans ma carrière et j'ai souvent rencontré des problèmes avec le modèle exact que vous avez démontré: pour enregistrer N entités dans la base de données, effectuer N insertions. Il est généralement plus efficace d'effectuer une insertion en bloc à l'aide d'une seule instruction.

D'un autre côté, nous ne voulons pas non plus optimiser prématurément. Si vous enregistrez généralement seulement 1 à 3 personnes à la fois, l'écriture d'un lot optimisé peut être exagérée.

Avec un test unitaire approprié, vous pouvez l'écrire comme vous l'avez mis en œuvre ci-dessus, et si vous trouvez 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. Naturellement, cela varie en fonction de la qualité des tests, alors testez généreusement et testez bien.

L'avantage secondaire du test unitaire de ce comportement est de servir de documentation sur son objectif. Cet exemple trivial peut être évident, mais étant donné le point suivant ci-dessous, il pourrait être très important.

Le troisième avantage, que d'autres ont souligné, est que vous pouvez tester des détails secrets qui sont très difficiles à tester avec des tests d'intégration ou d'acceptation. Par exemple, s'il est nécessaire que tous les utilisateurs soient enregistrés atomiquement, vous pouvez écrire un scénario de test pour cela, qui vous permet de savoir qu'il se comporte comme prévu, et sert également de documentation pour une exigence qui peut ne pas être évidente. aux nouveaux développeurs.

J'ajouterai une pensée que j'ai reçue d'un instructeur TDD. Ne testez pas la méthode. Testez le comportement. En d'autres termes, vous ne testez pas le fonctionnement de savePeople, vous testez que plusieurs utilisateurs peuvent être enregistrés en un seul appel.

J'ai trouvé ma capacité à faire des tests unitaires de qualité et TDD s'améliorer lorsque j'ai cessé de penser aux tests unitaires comme vérifiant qu'un programme fonctionne, mais plutôt, ils vérifient qu'une unité de code fait ce que j'attends. Ce sont différents. Ils ne vérifient pas que cela fonctionne, mais ils vérifient qu'il fait ce que je pense qu'il fait. Quand j'ai commencé à penser de cette façon, ma perspective a changé.

13
Brandon

Faut-il tester bakeCookies()? Oui.

Comment une fonction comme celle-ci devrait-elle être testée unitaire, en supposant que vous pensez qu'elle devrait l'être? Il est difficile pour moi d'imaginer tout 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. Regardez attentivement ce que la fonction est censée faire - elle est censée définir l'objet oven dans un état spécifique. En regardant le code, il apparaît que les états des objets pan et dough n'ont pas vraiment d'importance. Vous devez donc passer un objet oven (ou le simuler) et affirmer qu'il se trouve dans un état particulier à la fin de l'appel de fonction.

En d'autres termes, vous devez affirmer que bakeCookies() a cuit les cookies.

Pour les fonctions très courtes, les tests unitaires peuvent sembler être un peu plus que de la tautologie. Mais n'oubliez pas, votre programme va durer beaucoup plus longtemps que le temps que vous employez à l'écrire. Cette fonction peut ou non changer à l'avenir.

Les tests unitaires remplissent deux fonctions:

  1. Il teste que tout fonctionne. Il s'agit des tests unitaires de fonction les moins utiles et il semble que vous ne sembliez considérer cette fonctionnalité que lorsque vous posez la question.

  2. Il vérifie que les futures modifications du programme ne cassent pas les fonctionnalités précédemment mises en œuvre. C'est la fonction la plus utile des tests unitaires et elle empêche l'introduction de bogues dans les grands programmes. Il est utile dans le codage normal lors de l'ajout de fonctionnalités au programme, mais il est plus utile dans le refactoring et les optimisations où les algorithmes de base implémentant le programme sont radicalement modifiés sans changer tout comportement observable du programme.

Ne testez pas le code à l'intérieur de la fonction. Testez plutôt que la fonction fait ce qu'elle dit. Lorsque vous regardez les tests unitaires de cette façon (test des fonctions, pas du code), vous vous rendrez compte que vous ne testez jamais les constructions de langage ou même la logique d'application. Vous testez une API.

6
slebetman

Est-ce que savePeople () devrait être testé unitaire, ou de tels tests reviendraient-ils à tester la construction de langage forEach intégrée?

Oui. Mais vous pourriez le faire d'une manière qui ne ferait que retester la construction.

La chose à noter ici est comment cette fonction se comporte-t-elle lorsqu'un savePerson échoue à mi-chemin? Comment est-il censé fonctionner?

That est le type de comportement subtil que la fonction prévoit que vous devez appliquer avec des tests unitaires.

3
Telastyn

La clé ici est votre point de vue sur une fonction particulière comme triviale. La plupart de la programmation est triviale: attribuez une valeur, faites un peu de calcul, prenez une décision: si ceci alors cela, continuez une boucle jusqu'à ... Isolément, tout est trivial. Vous venez de passer en revue les 5 premiers chapitres de tout livre enseignant un langage de programmation.

Le fait que la rédaction d'un test soit si facile devrait être un signe que votre conception n'est pas si mauvaise. Préférez-vous une conception qui n'est pas facile à tester?

"Cela ne changera jamais." est ainsi que la plupart des projets ayant échoué démarrent. Un test unitaire détermine uniquement si l'unité fonctionne comme prévu dans un certain ensemble de circonstances. Faites-le passer, puis vous pouvez oublier ses détails d'implémentation et simplement l'utiliser. Utilisez cet espace cérébral pour la prochaine tâche.

Savoir que les choses fonctionnent comme prévu est très important et n'est pas anodin dans les grands projets et en particulier les grandes équipes. S'il y a une chose que les programmeurs ont en commun, c'est le fait que nous avons tous dû faire face au terrible code de quelqu'un d'autre. Le moins que l'on puisse faire, c'est de faire des tests. En cas de doute, écrivez un test et continuez.

3
JeffO

Je pense que votre question se résume à:

Comment tester à l'unité une fonction void sans qu'il s'agisse d'un test d'intégration?

Si nous changeons votre fonction de cuisson des cookies pour retourner des cookies par exemple, il devient immédiatement évident quel devrait être le test.

Si nous devons appeler pan.GetCookies après avoir appelé la fonction, nous pouvons nous demander si c'est vraiment un test d'intégration ou si nous ne testons pas simplement l'objet pan?

Je pense que vous avez raison de dire que le fait d'avoir des tests unitaires avec tout ce qui est moqué et de vérifier simplement les fonctions x y et z était appelé manque de valeur.

Mais! Je dirais que dans ces cas, vous devriez refactoriser vos fonctions void pour retourner un résultat testable OR utiliser des objets réels et faire un test d'intégration

--- Mise à jour pour l'exemple createNewUser

  • un nouvel enregistrement utilisateur doit être créé dans la base de données
  • un e-mail 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 deviens légèrement controversé. Je crée des implémentations factices concrètes pour les paramètres avec état

s'il vous plaît, chers lecteurs, essayez de contrôler votre rage!

donc...

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 le détail d'implémentation de la méthode testée du comportement souhaité. Une implémentation alternative:

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

Passera toujours le test unitaire. De plus, vous avez l'avantage de pouvoir réutiliser les objets fictifs à travers les tests et également les injecter dans votre application pour les tests d'interface utilisateur ou d'intégration.

1
Ewan

Est-ce que savePeople () devrait être testé unitaire, ou de tels tests reviendraient-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 à tester la réalisation 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 post. Lorsque vous décidez quoi tester, l'implémentation de l'API n'a pas (et ne devrait pas) d'importance.

Deuxièmement, votre test sera là pour vérifier les invariants lorsque la fonction change . Le fait qu'il ne change pas maintenant ne signifie pas que vous ne devriez pas passer 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 de C++, pour mes classes, j'ai tendance à écrire un test trivial qui instancie un objet et vérifie les invariants (assignables, réguliers, etc.). J'ai trouvé surprenant combien de fois ce test est cassé pendant le développement (en - par exemple - en ajoutant un membre non mobile dans une classe, par erreur).

1
utnapistim

Vous devriez également tester bakeCookies - que donnerait/devrait e..g bakeCookies(Egg, pan, oven) donner? Oeuf au plat ou une exception? À eux seuls, ni pan ni oven ne se soucieront des ingrédients réels, car aucun d'entre eux n'est censé le faire, mais bakeCookies devrait généralement produire des cookies. Plus généralement, cela peut dépendre de la façon dont dough est obtenu et s'il y a is une chance qu'il devienne simplement Egg ou par ex. water à la place.

0
Tobias Kienzler