Aller au contenu principal

Performance des tests unitaires JavaScript

· 12 minutes de lecture

Jest exécute des milliers de tests sur Facebook à tout moment, soit par une intégration continue, soit par un appel manuel des ingénieurs pendant le développement. Cela a bien fonctionné pendant des années, même lorsque les personnes travaillant sur Jest ont été transférées vers d'autres projets au sein de Facebook.

Cependant, à mesure que les ingénieurs ajoutaient de plus en plus de tests, nous avons remarqué que les performances de Jest n'allaient pas s'améliorer. En outre, au cours de l'année dernière, l'écosystème JavaScript a radicalement changé avec l'introduction de choses comme npm3 et Babel, ce que nous n'avions pas anticipé. Nous avons formé une nouvelle équipe de Jest pour traiter tous ces problèmes et nous partagerons nos progrès et nos plans sur ce blog à partir de maintenant.

Jest est un peu différent de la plupart des frameworks de test. Nous l'avons conçu pour bien fonctionner dans le contexte de l'infrastructure de Facebook :

  • Monorepo Sur Facebook, nous avons un énorme monorepo qui contient tout notre code JavaScript. Il y a de nombreuses raisons pour lesquelles cette approche est avantageuse pour nous et il y a un discours génial d'un ingénieur Google qui met en évidence tous les avantages et inconvénients des monorepos.
  • La boxe à sable Une autre fonctionnalité de Jest qui est importante pour Facebook est la façon dont elle virtualise l'environnement de test et enveloppe nécessite pour exécuter du code sandbox et isoler des tests individuels. Nous travaillons même à rendre Jest plus modulaire pour que nous puissions tirer parti de cette fonctionnalité dans d'autres cas d'utilisation non liés aux tests.
  • providesModule Si vous avez regardé l'un de nos projets JavaScript open source avant, vous avez peut-être remarqué que nous utilisons un en-tête @providesModule pour assigner des identifiants globalement uniques aux modules. Cela nécessite des outils personnalisés, mais cela nous permet de référencer des modules sans chemins relatifs, ce qui nous a aidés à avancer incroyablement vite. a évolué ainsi que notre organisation d'ingénierie a grandi et a favorisé le partage de code dans toute l'entreprise. Consultez RelayContainer pour un exemple de comment cela fonctionne en pratique. Un inconvénient à cette approche, cependant, est que nous sommes forcés de lire et d'analyser toute notre base de code JavaScript afin de résoudre une seule requête requise. Cela serait évidemment prohibitif sans mise en cache complète, en particulier pour un processus de courte durée comme Jest.

En raison de ces contraintes uniques, Jest ne pourra peut-être jamais être aussi rapide que les autres exécuteurs de test lorsqu'il s'exécute sur toute notre suite de tests. Cependant, les ingénieurs ont rarement besoin d'exécuter Jest sur toute notre suite de tests. Propulsé par une analyse statique dans le projet node-haste – nous avons pu faire le mode par défaut pour exécuter Jest sur Facebook jest --onlyChanged, ou jest -o. Dans ce mode, nous construisons un graphique de dépendance inversée pour ne trouver que les tests concernés qui doivent être exécutés en fonction des modules qui ont été modifiés.

Planification optimale d'une exécution de test

La plupart du temps, notre analyse statique détermine que plusieurs tests doivent être exécutés. Le nombre de tests affectés peut aller de quelques tests à des milliers. Afin d'accélérer ce processus, Jest effectue des parallélisations de tests entre les travailleurs. C'est génial parce que la majeure partie du développement de Facebook se produit sur des serveurs distants avec de nombreux cœurs de processeur.

Récemment, nous avons remarqué que Jest semblait souvent coincé « En attente de 3 tests » jusqu'à une minute vers la fin d'une course. Il s'est avéré que nous avions quelques tests vraiment lents dans notre base de code qui dominaient l'exécution. Bien que nous ayons pu accélérer de manière significative ces tests individuels, nous avons également modifié la façon dont Jest planifie le fonctionnement des tests. Auparavant, nous avions l'habitude de planifier des exécutions de tests basées sur la traversée du système de fichiers, qui était en fait assez aléatoire. Voici un exemple de 11 tests en blocs gris sur deux travailleurs. La taille du bloc est le temps d'exécution du test :

perf-planification de base

Nous avons fait un mélange aléatoire de tests rapides et lents, et l'un de nos tests les plus lents a fini par fonctionner comme presque tous les autres tests ont été terminés, au cours desquels le second worker a été inactif.

Nous avons modifié la planification des tests en fonction de leur taille de fichier, ce qui est généralement un bon proxy pour la durée à laquelle un test va prendre. Un test avec quelques milliers de lignes de code prend probablement plus de temps qu'un test avec 15 lignes de code. Tandis que cette vitesse accélère le test entier d'environ 10%, nous avons fini par trouver une meilleure heuristique : maintenant Jest stocke l'exécution de chaque test dans une cache et lors des exécutions suivantes, il planifie les tests les plus lents à exécuter en premier. Dans l'ensemble, cela a contribué à améliorer le temps d'exécution de tous les tests d'environ 20%.

Voici un exemple du même test avec une meilleure planification :

planifications améliorées par tranche

Parce que nous lançons d'abord des tests lents, Jest peut parfois prendre beaucoup de temps pour démarrer – nous n’imprimons les résultats qu’une fois le premier test terminé. Pour l'avenir, nous prévoyons d'abord d'exécuter les tests qui ont échoué précédemment, parce que l'obtention de cette information aux développeurs le plus rapidement possible importe le plus.

Inline Requires and Lazy Mocking

Si vous avez déjà écrit des tests avec Jasmine, ils ressemblent probablement à ceci :

const sum = require('sum');
describe('sum', () => {
it('works', () => {
expect(sum(5, 4)).toBe(9);
});
});

Une chose spéciale que nous faisons dans Jest est de réinitialiser la totalité du registre des modules après chaque test (appelez à il) pour vous assurer que les tests ne dépendent pas les uns des autres. Avant Jest, les tests individuels dépendaient les uns des autres et l'état interne des modules se divulguait souvent entre eux. Au fur et à mesure que les ingénieurs ont enlevé, réordonné ou refaçonné les tests, certains ont commencé à échouer, ce qui rend difficile pour les gens de comprendre ce qui se passait.

Chaque test de Jest reçoit une nouvelle copie de tous les modules, y compris les nouvelles versions de toutes les dépendances fictives qui prennent beaucoup de temps à générer pour chaque test. Un effet secondaire de ceci est que nous avons dû appeler require manuellement avant chaque test, comme ceci :

let sum;
describe('sum', () => {
beforeEach(() => {
sum = require('sum');
});
it('works', () => {
expect(sum(5, 4)).toBe(9);
});
it('works too', () => {
// This copy of sum is not the same as in the previous call to `it`.
expect(sum(2, 3)).toBe(5);
});
});

Nous avons construit une transformation babel appelée inline-requires qui supprime les instructions de haut niveau et les insère dans le code. Par exemple, la ligne const sum = require('sum'); sera retiré du code, mais chaque utilisation de la somme `dans le fichier sera remplacée parrequire('sum')`. Avec cette transformation, nous pouvons écrire des tests comme vous vous y attendiez dans Jasmine et le code se transforme en ceci :

describe('sum', () => {
it('works', () => {
expect(require('sum')(5, 4)).toBe(9);
});
});

Un grand effet secondaire de la ligne exige que nous n'ayons besoin que des modules que nous utilisons réellement dans le test lui-même, au lieu de tous les modules que nous avons utilisés dans le fichier entier.

Ce qui mène à une autre optimisation : le bouchon paresseux. L'idée est de bouchonner uniquement les modules à la demande, qui est combiné avec inline nous sauve de bouchonner beaucoup de modules et toutes leurs dépendances récursives.

Nous avons pu mettre à jour tous les tests en utilisant un codemod en un rien de temps - c'était un simple 50, 00 changement de code de ligne. Inline nécessite et paresseux mocking a amélioré le temps de test de 50%.

Le plugin babel inline-require est non seulement utile pour Jest mais aussi pour JavaScript normal. Il a été expédié par Bhuwan à tous les utilisateurs de facebook.com il y a juste une semaine et a considérablement amélioré le temps de démarrage.

Pour l'instant, si vous souhaitez utiliser cette transformation dans Jest, vous devrez l'ajouter manuellement à votre configuration Babel. Nous travaillons sur des moyens de faciliter la mise en œuvre de cette option.

Repo-Sync et mise en cache

La version open source de Jest était un fork de notre version interne, et nous synchroniserions Jest une fois tous les deux mois. Ce fut un processus manuel douloureux qui nécessitait de corriger de nombreux tests à chaque fois. Nous avons récemment mis à jour Jest et apporté la parité à toutes les plateformes (iOS, Android et web) et avons ensuite activé notre processus de synchronisation. Maintenant, chaque changement de Jest en open source est exécuté contre tous nos tests internes, et il n'y a qu'une seule version de Jest qui soit cohérente partout.

La première fonctionnalité dont nous avons pu profiter après le déblocage était le cache du préprocesseur. Si vous utilisez Babel avec Jest, Jest doit prétraiter chaque fichier JavaScript avant de pouvoir l'exécuter. Nous avons construit un calque de mise en cache de sorte que chaque fichier, lorsqu'il n'est pas modifié, ne doit être transformé qu'une seule fois. Après avoir libéré Jest, nous avons pu facilement réparer l'implémentation de l'open source et l'expédier sur Facebook. Cela a entraîné une autre victoire de 50% sur la performance. Parce que la cache ne fonctionne que sur la seconde exécution, l'heure de début froide de Jest n'a pas été affectée.

Nous avons également réalisé que nous faisions beaucoup d’opérations de trajectoire lorsque nous résolvons des besoins relatifs. Parce que le registre des modules est réinitialisé pour chaque test, il y a eu des milliers d'appels qui pourraient être mémoisés. Une grande optimisation a été d'ajouter beaucoup plus de mise en cache, pas seulement autour d'un seul test, mais aussi à travers les fichiers de test. Auparavant, nous générerions des métadonnées de module pour la fonction d'automocking une fois pour chaque fichier de test. L'objet qu'un module exporte ne change jamais, donc nous partageons maintenant ce code entre les fichiers de test. Malheureusement, comme JavaScript et Node.js n'ont pas de mémoire partagée, nous devons faire tout ce travail au moins une fois par processus de travail.

Tout remettre en question

Lorsque vous essayez d'améliorer les performances, il est important de plonger aussi dans les systèmes qui s'installent au-dessus et en dessous de votre système. Dans le cas de Jest, des choses comme Node.js et les fichiers de test eux-mêmes, par exemple. L'une des premières choses que nous avons faites était de mettre à jour Node.js à Facebook à partir des années 0,10 à iojs et ensuite à Node 4. La nouvelle version de V8 a contribué à améliorer les performances et a été assez facile à mettre à jour.

Nous avons remarqué que le module path dans Node. s est lent lors de la création de milliers d'opérations de chemin qui ont été corrigées dans Node 5.7. Jusqu'à ce que nous supprimions le support de Node 4 en interne sur Facebook, nous expédierons notre propre version du module fastpath.

Nous avons ensuite commencé à interroger le node-haste obsolète. Comme mentionné précédemment, le projet entier doit être analysé pour les en-têtes de @providesModule pour construire un graphique de dépendances. Quand ce système a été construit à l'origine, node_modules n'existait pas et notre explorateur de système de fichiers ne les excluait pas correctement.

Dans les versions précédentes, Jest lisait en fait tous les fichiers dans node_modules – ce qui a contribué au lente temps de démarrage de Jest. Lorsque nous avons repris Jest, nous avons remplacé l'ensemble du projet par une nouvelle implémentation, basée sur l'empaquetage de react-native. Le temps de démarrage de Jest est maintenant inférieur à une seconde, même sur les grands projets. L'équipe react-native David, Amjad et Martin ont fait un travail remarquable sur ce projet.

Tout additionner

Un grand nombre des changements ci-dessus ont amélioré le temps d'exécution du test de 10% ou parfois même de 50%. Nous avons commencé à une durée d'exécution d'environ 10 minutes pour tous les tests, et sans ces améliorations, nous serions probablement à environ 20 minutes. Après ces améliorations, cependant, il faut maintenant toujours environ 1 minute et 35 secondes pour exécuter tous nos tests !

Plus important encore, l'ajout de nouveaux tests entraîne une croissance très lente du temps d'exécution total. Les ingénieurs peuvent écrire et exécuter plus de tests sans en ressentir les coûts.

Avec les 0 récents de Jest. améliorations de la publication et de la performance à partir de l'intégration node-haste2, le temps d'exécution de la suite de test du Relay est passé de 60 secondes à environ 25 et la suite de test react-native se termine maintenant en moins de dix secondes sur un MacBook Pro.

Nous sommes très heureux des victoires que nous avons vues jusqu'à présent, et nous allons continuer à travailler sur Jest et à le rendre meilleur. Si vous êtes curieux de contribuer à Jest, n'hésitez pas à nous contacter sur GitHub, Discord ou Facebook :)