javascript-unit-testing-performance
Diese Seite wurde von PageTurner AI übersetzt (Beta). Nicht offiziell vom Projekt unterstützt. Fehler gefunden? Problem melden →
Jest führt bei Facebook ständig Tausende von Tests aus – entweder über Continuous Integration oder manuell von Entwicklern während der Entwicklung aufgerufen. Dies funktionierte jahrelang gut, selbst als die ursprünglichen Jest-Entwickler innerhalb von Facebook zu anderen Projekten wechselten.
Als jedoch immer mehr Tests hinzukamen, wurde klar, dass Jest's Performance nicht mithalten würde. Zudem hat sich das JavaScript-Ökosystem im letzten Jahr dramatisch verändert durch Einführungen wie npm3 und Babel, die wir nicht vorhergesehen hatten. Wir bildeten ein neues Jest-Team, um diese Probleme anzugehen, und werden unsere Fortschritte und Pläne fortan in diesem Blog teilen.
Jest unterscheidet sich etwas von den meisten Test-Runnern. Wir haben es für die Infrastruktur bei Facebook optimiert:
-
Monorepo Bei Facebook nutzen wir ein riesiges Monorepo, das unseren gesamten JavaScript-Code enthält. Dieser Ansatz bietet viele Vorteile für uns, und ein exzellenter Vortrag eines Google-Ingenieurs beleuchtet Vor- und Nachteile von Monorepos.
-
Sandboxing Ein weiteres für Facebook wichtiges Jest-Feature ist die Virtualisierung der Testumgebung und das Wrapping von
require, um Code-Execution zu sandboxen und Tests zu isolieren. Wir arbeiten sogar daran, Jest modularer zu machen, um diese Funktion auch in Nicht-Test-Szenarien nutzen zu können. -
providesModule Wer unsere Open-Source-JavaScript-Projekte kennt, hat vielleicht den
@providesModule-Header bemerkt, der Modulen global eindeutige IDs zuweist. Dies erfordert zwar spezielle Tools, ermöglicht aber Modulreferenzen ohne relative Pfade – was uns extrem schnelle Entwicklung, gute Skalierung mit wachsenden Teams und unternehmensweite Code-Nutzung brachte. Siehe RelayContainer für ein Praxisbeispiel. Der Nachteil: Für ein einzelnes require müssen wir das gesamte Codebase lesen und parsen. Ohne starkes Caching wäre das unverhältnismäßig teuer – besonders für kurzlebige Prozesse wie Jest.
Durch diese Besonderheiten wird Jest bei unserem gesamten Test-Suite vielleicht nie so schnell wie andere Runner. Aber Entwickler müssen selten alle Tests laufen lassen. Mittels statischer Analyse im node-haste-Projekt ist der Standardmodus bei Facebook nun jest --onlyChanged (oder jest -o). Dabei erstellen wir einen Rückwärts-Abhängigkeitsgraphen, um nur betroffene Tests für geänderte Module zu finden.
Optimale Planung von Testläufen
Meistens identifiziert unsere Analyse mehrere benötigte Tests – von wenigen bis zu Tausenden. Zur Beschleunigung parallelisiert Jest Testläufe über Worker. Das ist ideal, da Facebook-Entwicklung meist auf Remote-Servern mit vielen CPU-Kernen stattfindet.
Kürzlich bemerkten wir, dass Jest gegen Testende oft minutenlang bei "Waiting for 3 tests" hing. Grund waren einige extrem langsame Tests, die die Laufzeit dominierten. Neben deren Beschleunigung änderten wir die Test-Planung: Statt zufälliger Dateisystem-Traversierung (siehe Grafik) zeigen 11 Tests als graue Blöcke auf zwei Workern – Blockgröße = Testdauer:
Wir liefen zufällig schnelle und langsame Tests gemischt, wobei einer der langsamsten Tests startete, als fast alle anderen bereits fertig waren – während der zweite Worker untätig blieb.
Wir änderten die Testplanung basierend auf Dateigrößen – ein guter Indikator für die Testdauer. Ein Test mit tausend Codezeilen dauert typischerweise länger als einer mit 15 Zeilen. Diese Optimierung beschleunigte Testläufe um etwa 10%, aber wir fanden eine bessere Heuristik: Jest speichert nun die Ausführungsdauer jedes Tests im Cache und priorisiert bei Folgeläufen die langsamsten Tests. Insgesamt verbesserte dies die Gesamtlaufzeit um etwa 20%.
Hier derselbe Testlauf mit optimierter Planung:
Da langsame Tests zuerst laufen, kann Jest anfangs träge wirken – Ergebnisse werden erst nach Abschluss des ersten Tests angezeigt. Zukünftig wollen wir zuvor fehlgeschlagene Tests priorisieren, denn deren schnelle Rückmeldung ist für Entwickler am wichtigsten.
Inline Requires und Lazy Mocking
Wenn Sie bereits Jasmine-Tests geschrieben haben, sehen diese wahrscheinlich so aus:
const sum = require('sum');
describe('sum', () => {
it('works', () => {
expect(sum(5, 4)).toBe(9);
});
});
Jests Besonderheit ist das Zurücksetzen des gesamten Modulregisters nach jedem Test (jeder it-Aufruf), um Testabhängigkeiten zu vermeiden. Vorher beeinflussten sich Tests gegenseitig durch verbleibenden Modulzustand – Änderungen führten zu rätselhaften Fehlern.
Jeder Jest-Test erhält frische Modulinstanzen, auch neue Versionen gemockter Abhängigkeiten, deren Erzeugung zeitintensiv ist. Deshalb mussten wir require manuell vor jedem Test aufrufen:
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);
});
});
Wir entwickelten die Babel-Transformation inline-requires, die Top-Level-require-Aufrufe entfernt und im Code einfügt. Beispiel: const sum = require('sum'); entfällt, aber jeder sum-Verweis wird durch require('sum') ersetzt. So können wir Tests normal schreiben:
describe('sum', () => {
it('works', () => {
expect(require('sum')(5, 4)).toBe(9);
});
});
Ein positiver Nebeneffekt: Es werden nur tatsächlich verwendete Module geladen, nicht alle im File importierten.
Das ermöglicht lazy mocking: Module werden nur bei Bedarf gemockt. Kombiniert mit inline-requires spart dies das Mocken unnötiger Abhängigkeiten.
Wir aktualisierten alle Tests via Codemod – eine einfache Änderung mit 50.000 Codezeilen. Inline-requires und lazy mocking reduzierten die Testlaufzeit um 50%.
Das inline-require-Babel-Plugin nutzt nicht nur Jest, sondern auch normalem JavaScript. Bhuwan rollte es vor einer Woche für facebook.com aus und verbesserte die Startzeit deutlich.
Aktuell müssen Sie dieses Plugin manuell in Ihrer Babel-Konfiguration hinzufügen. Wir arbeiten an einer vereinfachten Opt-in-Lösung.
Repo-Sync und Caching
Die Open-Source-Version von Jest war früher ein Fork unserer internen Version – wir synchronisierten nur alle paar Monate. Dieser mühsame Prozess erforderte jedes Mal Testanpassungen. Kürzlich vereinheitlichten wir Jest für alle Plattformen (iOS, Android, Web) und aktivierten den Sync-Prozess: Jede Open-Source-Änderung läuft nun gegen interne Tests, und es existiert nur noch eine konsistente Jest-Version.
Die erste Funktion, die wir nach dem Entforking nutzen konnten, war der Preprocessor-Cache. Wenn Sie Babel mit Jest verwenden, muss Jest jede JavaScript-Datei vorverarbeiten, bevor sie ausgeführt werden kann. Wir haben eine Caching-Schicht implementiert, sodass jede unveränderte Datei nur einmal transformiert werden muss. Nachdem wir Jest entforkt hatten, konnten wir die Open-Source-Implementierung problemlos anpassen und bei Facebook ausrollen. Dies brachte einen weiteren Leistungsgewinn von 50%. Da der Cache jedoch nur bei wiederholten Läufen greift, blieb die Kaltstartzeit von Jest unverändert.
Wir stellten außerdem fest, dass wir bei der Auflösung relativer require-Aufrufe viele Pfadoperationen durchführten. Da die Modul-Registry für jeden Test zurückgesetzt wird, gab es Tausende von Aufrufen, die zwischengespeichert werden konnten. Eine große Optimierung war die Einführung zusätzlicher Caching-Mechanismen – nicht nur innerhalb einzelner Tests, sondern auch testdateiübergreifend. Früher generierten wir Metadaten für die Automocking-Funktion pro Testdatei neu. Da sich die exportierten Module jedoch nie ändern, teilen wir diesen Code jetzt zwischen Testdateien. Leider müssen wir diese Arbeit mindestens einmal pro Worker-Prozess wiederholen, da JavaScript und Node.js keinen Shared Memory unterstützen.
Alles hinterfragen
Bei Leistungsoptimierungen ist es entscheidend, auch über- und untergeordnete Systeme zu untersuchen – bei Jest beispielsweise Node.js oder die Testdateien selbst. Eine unserer ersten Maßnahmen war das Upgrade von Node.js bei Facebook von der veralteten Version 0.10 auf iojs und später Node 4. Die neue V8-Engine verbesserte die Performance spürbar und war einfach zu integrieren.
Wir bemerkten, dass das path-Modul von Node.js bei tausenden Pfadoperationen langsam war, was in Node 5.7 behoben wurde. Bis wir Node 4 bei Facebook abschaffen, verwenden wir unsere eigene fastpath-Implementierung.
Als Nächstes hinterfragten wir das veraltete node-haste. Wie erwähnt muss das gesamte Projekt nach @providesModule-Headern durchsucht werden, um einen Abhängigkeitsgraphen zu erstellen. Als dieses System entstand, existierte node_modules noch nicht, und unser Dateisystem-Crawler schloss diese Ordner nicht korrekt aus.
In früheren Versionen las Jest tatsächlich jede Datei in node_modules – was die langsame Startzeit verursachte. Bei unserer Rückkehr zu Jest ersetzten wir das gesamte Projekt durch eine neue Implementierung basierend auf react-natives Packager. Die Startzeit von Jest beträgt jetzt selbst bei großen Projekten weniger als eine Sekunde. Das react-native-Team, insbesondere David, Amjad und Martin, leistete hier herausragende Arbeit.
Die Gesamtbilanz
Viele dieser Änderungen brachten Leistungssteigerungen von 10% bis 50%. Ursprünglich dauerten alle Tests etwa 10 Minuten – ohne diese Optimierungen wären wir heute bei etwa 20 Minuten. Nach den Verbesserungen benötigen wir jetzt konstant nur noch ca. 1 Minute 35 Sekunden.
Noch wichtiger: Neue Tests erhöhen die Gesamtlaufzeit kaum merklich. Entwickler können mehr Tests schreiben und ausführen, ohne Performance-Einbußen zu spüren.
Durch Jests Release 0.9 und die node-haste2-Integration sank die Laufzeit der Relay-Testsuite von 60 auf 25 Sekunden, während die react-native-Testsuite auf einem 13" MacBook Pro jetzt unter 10 Sekunden bleibt.
Wir sind mit diesen Erfolgen sehr zufrieden und werden Jest weiter verbessern. Falls Sie mitwirken möchten, kontaktieren Sie uns gerne auf GitHub, Discord oder Facebook :)
