Erreurs désagréables lors de l'écriture des tests unitaires

L'autre jour, je ferai un rapport interne dans lequel je parlerai à nos développeurs des erreurs désagréables qui peuvent survenir lors de l'écriture des tests unitaires. Les erreurs les plus désagréables de mon point de vue sont lorsque les tests réussissent, mais en même temps, ils le font si mal qu'il vaut mieux ne pas réussir. Et j'ai décidé de partager des exemples de telles erreurs avec tout le monde. Sûrement autre chose à dire de cette région. Des exemples sont écrits pour Node.JS et Mocha, mais en général, ces erreurs sont vraies pour tout autre écosystème.

Pour le rendre plus intéressant, certains d'entre eux sont encadrés sous la forme d'un code de problème et d'un spoiler, ouvrant qui, vous verrez quel était le problème. Je vous recommande donc de regarder d'abord le code, d'y trouver une erreur, puis d'ouvrir le spoiler. Aucune solution aux problèmes ne sera indiquée - je propose d'y penser nous-mêmes. Tout simplement parce que je suis paresseux. L'ordre de la liste n'a pas de sens profond - c'est juste une séquence dans laquelle j'ai rappelé toutes sortes de problèmes réels qui nous ont fait pleurer. Beaucoup de choses vous sembleront sûrement évidentes - mais même les développeurs expérimentés peuvent accidentellement écrire un tel code.


Alors allons-y.

0. Manque de tests


Curieusement, beaucoup croient encore que l'écriture de tests ralentit la vitesse de développement. Bien sûr, il est évident qu'il faut passer plus de temps à écrire des tests et à écrire du code qui peut être testé. Mais après le débogage et la régression après cela, vous devez passer plusieurs fois plus de temps ...

1. L'absence de tests en cours


Si vous avez des tests que vous n'exécutez pas, ou exécutez de temps en temps, alors c'est comme l'absence de tests. Et c'est encore pire - vous avez un code de test obsolète et un faux sentiment de sécurité. Les tests doivent au moins s'exécuter dans les processus CI lors de la transmission de code à une branche. Et mieux - localement avant la poussée. Ensuite, le développeur n'aura pas à revenir à la build dans quelques jours, ce qui, en fait, ne s'est pas passé.

2. Absence de couverture


Si vous ne savez toujours pas quelle est la couverture des tests, il est temps d'aller lire dès maintenant. Au moins Wikipédia . Sinon, il y a de fortes chances que votre test vérifie 10% du code que vous pensez qu'il vérifie. Tôt ou tard, vous marcherez certainement dessus. Bien sûr, même une couverture à 100% du code ne garantit en aucune façon son exactitude complète - mais c'est bien mieux que le manque de couverture car cela vous montrera beaucoup plus d'erreurs potentielles. Pas étonnant que les dernières versions de Node.JS aient même des outils intégrés pour le lire. En général, le sujet de la couverture est profond et extrêmement holistique, mais je ne m'y attarderai pas trop - je veux en dire un peu plus sur beaucoup.

3.



const {assert} = require('chai'); const Promise = require('bluebird'); const sinon = require('sinon'); class MightyLibrary { static someLongFunction() { return Promise.resolve(1); // just imagine a really complex and long function here } } async function doItQuickOrFail() { let res; try { res = await MightyLibrary.someLongFunction().timeout(1000); } catch (err) { if (err instanceof Promise.TimeoutError) { return false; } throw err; } return res; } describe('using Timeouts', ()=>{ it('should return false if waited too much', async ()=>{ // stub function to emulate looong work sinon.stub(MightyLibrary, 'someLongFunction').callsFake(()=>Promise.delay(10000).then(()=>true)); const res = await doItQuickOrFail(); assert.equal(res, false); }); }); 


Qu'est-ce qui ne va pas ici
Délais d'attente dans les tests unitaires.

Ici, ils voulaient vérifier que le réglage des délais d'attente pour une longue opération fonctionne vraiment. En général, cela n'a aucun sens de toute façon - vous ne devriez pas vérifier les bibliothèques standard - mais ce code entraîne également un autre problème - pour augmenter le temps d'exécution des tests pendant une seconde. Il semblerait que ce ne soit pas tellement ... Mais multipliez cette seconde par le nombre de tests similaires, par le nombre de développeurs, par le nombre de lancements par jour ... Et vous comprendrez qu'à cause de tels délais d'attente, vous pouvez perdre de nombreuses heures de travail par semaine, sinon quotidiennement.



4.



 const fs = require('fs'); const testData = JSON.parse(fs.readFileSync('./testData.json', 'utf8')); describe('some block', ()=>{ it('should do something', ()=>{ someTest(testData); }) }) 


Qu'est-ce qui ne va pas ici
Chargement des données de test en dehors des blocs de test.

À première vue, il semble que peu importe où lire les données de test - dans la description, elles bloquent ou dans le module lui-même. Le deuxième aussi. Mais imaginez que vous ayez des centaines de tests, et que beaucoup d'entre eux utilisent des données lourdes. Si vous les chargez en dehors du test, cela entraînera le fait que toutes les données de test resteront en mémoire jusqu'à la fin de l'exécution du test, et au fil du temps, le lancement consommera de plus en plus de RAM - jusqu'à ce qu'il s'avère que les tests ne s'exécutent plus du tout machines de travail standard.



5.



 const {assert} = require('chai'); const sinon = require('sinon'); class Dog { // eslint-disable-next-line class-methods-use-this say() { return 'Wow'; } } describe('stubsEverywhere', ()=>{ before(()=>{ sinon.stub(Dog.prototype, 'say').callsFake(()=>{ return 'meow'; }); }); it('should say meow', ()=>{ const dog = new Dog(); assert.equal(dog.say(), 'meow', 'dog should say "meow!"'); }); }); 


Qu'est-ce qui ne va pas ici
Le code est en fait remplacé par des talons.

Vous avez sûrement immédiatement vu cette erreur ridicule. Dans le vrai code, cela, bien sûr, n'est pas si évident - mais j'ai vu du code tellement accroché avec des talons que je n'ai rien testé du tout.



6.



 const sinon = require('sinon'); const {assert} = require('chai'); class Widget { fetch() {} loadData() { this.fetch(); } } if (!sinon.sandbox || !sinon.sandbox.stub) { sinon.sandbox = sinon.createSandbox(); } describe('My widget', () => { it('is awesome', () => { const widget = new Widget(); widget.fetch = sinon.sandbox.stub().returns({ one: 1, two: 2 }); widget.loadData(); assert.isTrue(widget.fetch.called); }); }); 


Qu'est-ce qui ne va pas ici
Dépendance entre les tests.

À première vue, il est clair qu'ils ont oublié d'écrire ici

  afterEach(() => { sinon.sandbox.restore(); }); 


Mais le problème n'est pas seulement ceci, mais le même bac à sable est utilisé pour tous les tests. Et il est très facile de confondre l'environnement d'exécution des tests de telle manière qu'ils commencent à dépendre les uns des autres. Après cela, les tests ne commenceront à être effectués que dans un certain ordre, et en général, il ne sera pas clair quoi tester.

Heureusement, à un moment donné, sinon.sandbox a été déclaré obsolète et supprimé, de sorte que vous ne pouvez rencontrer un tel problème que sur un projet hérité - mais il existe tellement d'autres façons de confondre l'environnement d'exécution des tests de telle sorte qu'il sera douloureusement douloureux d'enquêter plus tard. quel code est coupable d'un comportement incorrect. Soit dit en passant, il y a récemment eu un message sur un hub sur une sorte de modèle comme «Ice Factory» - ce n'est pas une panacée, mais parfois cela aide dans de tels cas.




7. D'énormes données de test dans le fichier de test



Très souvent, j'ai vu comment les énormes fichiers JSON, et même XML, se trouvaient directement dans le test. Je pense que c'est évident pourquoi cela ne vaut pas la peine d'être fait - cela devient pénible à regarder, à éditer, et aucun IDE ne vous en remerciera. Si vous disposez de données de test volumineuses, supprimez-les du fichier de test.

8.


 const {assert} = require('chai'); const crypto = require('crypto'); describe('extraTests', ()=>{ it('should generate unique bytes', ()=>{ const arr = []; for (let i = 0; i < 1000; i++) { const value = crypto.randomBytes(256); arr.push(value); } const unique = arr.filter((el, index)=>arr.indexOf(el) === index); assert.equal(arr.length, unique.length, 'Data is not random enough!'); }); }); 


Qu'est-ce qui ne va pas ici
Tests supplémentaires.

Dans ce cas, le développeur était très préoccupé par le fait que ses identifiants uniques seraient uniques, il a donc rédigé un chèque pour cela. En général, un désir compréhensible - mais il est préférable de lire la documentation ou d'exécuter un tel test plusieurs fois sans l'ajouter au projet. L'exécuter dans chaque build n'a aucun sens.

Eh bien, le lien pour les valeurs aléatoires dans le test est en soi un excellent moyen de se tirer une balle dans le pied en faisant un test instable à partir de zéro.



9. Manque de mok


Il est beaucoup plus facile d'exécuter des tests avec une base de données en direct et des services à 100%, et d'exécuter des tests sur eux.
Mais tôt ou tard, cela reviendra à son terme - des tests de suppression de données seront exécutés sur la base de produits, commenceront à tomber en raison d'un service partenaire défectueux, ou votre CI n'aura tout simplement pas de base sur laquelle les exécuter. En général, l'élément est assez holistique, mais en règle générale - si vous pouvez émuler des services externes, il est préférable de le faire.

11.


 const {assert} = require('chai'); class CustomError extends Error { } function mytestFunction() { throw new CustomError('important message'); } describe('badCompare', ()=>{ it('should throw only my custom errors', ()=>{ let errorHappened = false; try { mytestFunction(); } catch (err) { errorHappened = true; assert.isTrue(err instanceof CustomError); } assert.isTrue(errorHappened); }); }); 


Qu'est-ce qui ne va pas ici
Débogage d'erreur compliqué.

Tout n'est pas mal, mais il y a un problème - si le test se bloque soudainement, vous verrez une erreur de forme

1) badCompare
should throw only my custom errors:

AssertionError: expected false to be true
+ expected - actual

-false
+true

at Context.it (test/011_badCompare/test.js:23:14)


De plus, pour comprendre quel type d'erreur s'est réellement produit - vous devez réécrire le test. Donc, en cas d'erreur inattendue - essayez de faire en sorte que le test en parle, et pas seulement le fait que cela se soit produit.



12.


 const {assert} = require('chai'); function someVeryBigFunc1() { return 1; // imagine a tonn of code here } function someVeryBigFunc2() { return 2; // imagine a tonn of code here } describe('all Before Tests', ()=>{ let res1; let res2; before(async ()=>{ res1 = await someVeryBigFunc1(); res2 = await someVeryBigFunc2(); }); it('should return 1', ()=>{ assert.equal(res1, 1); }); it('should return 2', ()=>{ assert.equal(res2, 2); }); }); 


Qu'est-ce qui ne va pas ici
Tout dans le bloc avant.

Il semblerait qu'une approche intéressante consiste à effectuer toutes les opérations dans le bloc «avant», et donc à ne laisser que des vérifications à l'intérieur du «il».
Pas vraiment.
Parce que dans ce cas, il y a un gâchis dans lequel vous ne pouvez ni comprendre le moment de l'exécution réelle des tests, ni la raison de la chute, ni ce qui se rapporte à un test et quoi à un autre.
Ainsi, tout le travail du test (à l'exception des initialisations standard) doit être effectué à l'intérieur du test lui-même.



13.


 const {assert} = require('chai'); const moment = require('moment'); function someDateBasedFunction(date) { if (moment().isAfter(date)) { return 0; } return 1; } describe('useFutureDate', ()=>{ it('should return 0 for passed date', ()=>{ const pastDate = moment('2010-01-01'); assert.equal(someDateBasedFunction(pastDate), 0); }); it('should return 1 for future date', ()=>{ const itWillAlwaysBeInFuture = moment('2030-01-01'); assert.equal(someDateBasedFunction(itWillAlwaysBeInFuture), 1); }); }); 


Qu'est-ce qui ne va pas ici
Attachez sur les dates.

Cela semblerait également être une erreur évidente - mais cela se produit également périodiquement chez les développeurs fatigués qui croient déjà que demain ne viendra jamais. Et la construction qui allait bien hier tombe soudainement aujourd'hui.

N'oubliez pas que n'importe quelle date viendra tôt ou tard - alors utilisez l'émulation de temps avec des choses comme `sinon.fakeTimers`, ou au moins définissez des dates à distance comme 2050 - laissez vos descendants blesser ...



14.



 describe('dynamicRequires', ()=>{ it('should return english locale', ()=>{ // HACK : // Some people mutate locale in tests to chinese so I will require moment here // eslint-disable-next-line global-require const moment = require('moment'); const someDate = moment('2010-01-01').format('MMMM'); assert.equal(someDate, 'January'); }); }); 


Qu'est-ce qui ne va pas ici
Chargement dynamique des modules.

Si vous avez Eslint, vous avez probablement déjà interdit les dépendances dynamiques. Ou pas.
Souvent, je vois que les développeurs essaient de charger des bibliothèques ou divers modules directement à l'intérieur des tests. Cependant, ils savent généralement comment «require» fonctionne - mais ils préfèrent l'illusion qu'ils sont censés recevoir un module propre que personne n'a confondu jusqu'à présent.
Cette hypothèse est dangereuse dans la mesure où le chargement de modules supplémentaires pendant les tests est plus lent et conduit à nouveau à un comportement plus indéfini.



15.


 function someComplexFunc() { // Imagine a piece of really strange code here return 1; } describe('cryptic', ()=>{ it('success', ()=>{ const result = someComplexFunc(); assert.equal(result, 1); }); it('should not fail', ()=>{ const result = someComplexFunc(); assert.equal(result, 1); }); it('is right', ()=>{ const result = someComplexFunc(); assert.equal(result, 1); }); it('makes no difference for solar system', ()=>{ const result = someComplexFunc(); assert.equal(result, 1); }); }); 


Qu'est-ce qui ne va pas ici
Noms de test incompréhensibles.

Vous devez être fatigué des choses évidentes, non? Mais vous devez encore en parler parce que beaucoup ne prennent pas la peine d'écrire des noms compréhensibles pour les tests - et en conséquence, il est possible de comprendre ce qu'un test particulier fait seulement après de nombreuses recherches.



16.


 const {assert} = require('chai'); const Promise = require('bluebird'); function someTomeoutingFunction() { throw new Promise.TimeoutError(); } describe('no Error check', ()=>{ it('should throw error', async ()=>{ let timedOut = false; try { await someTomeoutingFunction(); } catch (err) { timedOut = true; } assert.equal(timedOut, true); }); }); 


Qu'est-ce qui ne va pas ici
Manque de vérification de l'erreur levée.

Souvent, vous devez vérifier que, dans certains cas, la fonction génère une erreur. Mais vous devez toujours vérifier si ce sont les droïdes que nous recherchons - car il peut soudainement se produire qu'une autre erreur a été rejetée, dans un autre endroit et pour d'autres raisons ...



17.



 function someBadFunc() { throw new Error('I am just wrong!'); } describe.skip('skipped test', ()=>{ it('should be fine', ()=>{ someBadFunc(); }); }); 


Qu'est-ce qui ne va pas ici
Tests désactivés.

Bien sûr, une situation peut toujours se produire lorsque le code a déjà été testé plusieurs fois avec vos mains, vous devez le lancer de toute urgence et, pour une raison quelconque, le test ne fonctionne pas. Par exemple, en raison de la complication pas évidente d'un autre test, dont j'ai parlé plus tôt. Et le test est désactivé. Et c'est normal. Pas normal - ne définissez pas immédiatement la tâche de réactiver le test. Si cela n'est pas fait, le nombre de tests désactivés se multipliera et leur code deviendra constamment obsolète. Jusqu'à ce que la seule option reste - faites preuve de pitié et lancez tous ces tests nafig, car il est plus rapide de les réécrire que de comprendre les erreurs.



Voici une telle sélection. Tous ces tests réussissent bien, mais ils sont brisés par conception. Ajoutez vos options dans les commentaires ou dans le référentiel que j'ai fait pour collecter de telles erreurs.

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


All Articles