Testbaren Code in JavaScript schreiben: Ein kurzer Überblick
Veröffentlicht: 2022-03-11Ob wir Node zusammen mit einem Test-Framework wie Mocha oder Jasmine verwenden oder DOM-abhängige Tests in einem Headless-Browser wie PhantomJS ausführen, unsere Optionen für Komponententests von JavaScript sind jetzt besser als je zuvor.
Das bedeutet jedoch nicht, dass der Code, den wir testen, für uns so einfach ist wie unsere Tools! Das Organisieren und Schreiben von Code, der leicht testbar ist, erfordert einige Mühe und Planung, aber es gibt einige Muster, die von funktionalen Programmierkonzepten inspiriert sind und die wir verwenden können, um zu vermeiden, dass wir beim Testen unseres Codes in eine schwierige Situation geraten. In diesem Artikel werden wir einige nützliche Tipps und Muster zum Schreiben von testbarem Code in JavaScript durchgehen.
Halten Sie Geschäftslogik und Anzeigelogik getrennt
Eine der Hauptaufgaben einer JavaScript-basierten Browseranwendung besteht darin, vom Endbenutzer ausgelöste DOM-Ereignisse abzuhören und dann darauf zu reagieren, indem eine Geschäftslogik ausgeführt und die Ergebnisse auf der Seite angezeigt werden. Es ist verlockend, eine anonyme Funktion zu schreiben, die den Großteil der Arbeit genau dort erledigt, wo Sie Ihre DOM-Ereignis-Listener einrichten. Das Problem, das dadurch entsteht, ist, dass Sie jetzt DOM-Ereignisse simulieren müssen, um Ihre anonyme Funktion zu testen. Dies kann sowohl in Codezeilen als auch in der Zeit, die zum Ausführen von Tests benötigt wird, zu Overhead führen.
Schreiben Sie stattdessen eine benannte Funktion und übergeben Sie sie an den Ereignishandler. Auf diese Weise können Sie Tests für benannte Funktionen direkt und ohne Umwege schreiben, um ein gefälschtes DOM-Ereignis auszulösen.
Dies gilt jedoch für mehr als das DOM. Viele APIs, sowohl im Browser als auch in Node, sind darauf ausgelegt, Ereignisse auszulösen und abzuhören oder darauf zu warten, dass andere Arten von asynchroner Arbeit abgeschlossen werden. Als Faustregel gilt: Wenn Sie viele anonyme Callback-Funktionen schreiben, ist Ihr Code möglicherweise nicht einfach zu testen.
// hard to test $('button').on('click', () => { $.getJSON('/path/to/data') .then(data => { $('#my-list').html('results: ' + data.join(', ')); }); }); // testable; we can directly run fetchThings to see if it // makes an AJAX request without having to trigger DOM // events, and we can run showThings directly to see that it // displays data in the DOM without doing an AJAX request $('button').on('click', () => fetchThings(showThings)); function fetchThings(callback) { $.getJSON('/path/to/data').then(callback); } function showThings(data) { $('#my-list').html('results: ' + data.join(', ')); }
Verwenden Sie Callbacks oder Promises mit asynchronem Code
Im obigen Codebeispiel führt unsere umgestaltete fetchThings-Funktion eine AJAX-Anfrage aus, die den größten Teil ihrer Arbeit asynchron erledigt. Das bedeutet, dass wir die Funktion nicht ausführen und testen können, ob sie alles getan hat, was wir erwartet haben, weil wir nicht wissen, wann sie fertig ist.
Die häufigste Methode zur Lösung dieses Problems besteht darin, eine Callback-Funktion als Parameter an die asynchron ausgeführte Funktion zu übergeben. In Ihren Komponententests können Sie Ihre Zusicherungen in dem Callback ausführen, den Sie übergeben.
Eine weitere gängige und zunehmend beliebte Methode zum Organisieren von asynchronem Code ist die Promise-API. Glücklicherweise geben $.ajax und die meisten anderen asynchronen Funktionen von jQuery bereits ein Promise-Objekt zurück, sodass viele gängige Anwendungsfälle bereits abgedeckt sind.
// hard to test; we don't know how long the AJAX request will run function fetchData() { $.ajax({ url: '/path/to/data' }); } // testable; we can pass a callback and run assertions inside it function fetchDataWithCallback(callback) { $.ajax({ url: '/path/to/data', success: callback, }); } // also testable; we can run assertions when the returned Promise resolves function fetchDataWithPromise() { return $.ajax({ url: '/path/to/data' }); }
Vermeiden Sie Nebenwirkungen
Schreiben Sie Funktionen, die Argumente annehmen und einen Wert zurückgeben, der ausschließlich auf diesen Argumenten basiert, genau wie Zahlen in eine mathematische Gleichung einzufügen, um ein Ergebnis zu erhalten. Wenn Ihre Funktion von einem externen Zustand abhängt (z. B. den Eigenschaften einer Klasseninstanz oder dem Inhalt einer Datei) und Sie diesen Zustand vor dem Testen Ihrer Funktion einrichten müssen, müssen Sie in Ihren Tests weitere Einstellungen vornehmen. Sie müssen darauf vertrauen, dass kein anderer Code, der ausgeführt wird, denselben Zustand ändert.
Vermeiden Sie in gleicher Weise das Schreiben von Funktionen, die den externen Zustand ändern (wie das Schreiben in eine Datei oder das Speichern von Werten in einer Datenbank), während es ausgeführt wird. Dies verhindert Nebenwirkungen, die Ihre Fähigkeit beeinträchtigen könnten, anderen Code sicher zu testen. Im Allgemeinen ist es am besten, Seiteneffekte so nah wie möglich an den Rändern Ihres Codes zu halten, mit so wenig „Oberfläche“ wie möglich. Im Falle von Klassen und Objektinstanzen sollten die Seiteneffekte einer Klassenmethode auf den Zustand der getesteten Klasseninstanz beschränkt sein.
// hard to test; we have to set up a globalListOfCars object and set up a // DOM with a #list-of-models node to test this code function processCarData() { const models = globalListOfCars.map(car => car.model); $('#list-of-models').html(models.join(', ')); } // easy to test; we can pass an argument and test its return value, without // setting any global values on the window or checking the DOM the result function buildModelsString(cars) { const models = cars.map(car => car.model); return models.join(','); }
Verwenden Sie Abhängigkeitsinjektion
Ein gängiges Muster zur Reduzierung der Verwendung externer Zustände durch eine Funktion ist die Abhängigkeitsinjektion, bei der alle externen Anforderungen einer Funktion als Funktionsparameter übergeben werden.

// depends on an external state database connector instance; hard to test function updateRow(rowId, data) { myGlobalDatabaseConnector.update(rowId, data); } // takes a database connector instance in as an argument; easy to test! function updateRow(rowId, data, databaseConnector) { databaseConnector.update(rowId, data); }
Einer der Hauptvorteile der Verwendung von Dependency Injection besteht darin, dass Sie Scheinobjekte aus Ihren Komponententests übergeben können, die keine echten Nebeneffekte verursachen (in diesem Fall das Aktualisieren von Datenbankzeilen), und Sie können einfach behaupten, dass Ihr Scheinobjekt bearbeitet wurde in der erwarteten Weise.
Geben Sie jeder Funktion einen einzigen Zweck
Brechen Sie lange Funktionen, die mehrere Dinge tun, in eine Sammlung kurzer Funktionen für einen bestimmten Zweck auf. Dies macht es viel einfacher zu testen, ob jede Funktion ihren Teil richtig macht, anstatt zu hoffen, dass eine große Funktion alles richtig macht, bevor sie einen Wert zurückgibt.
Bei der funktionalen Programmierung wird das Aneinanderreihen mehrerer Einzweckfunktionen als Komposition bezeichnet. Underscore.js hat sogar eine Funktion _.compose
, die eine Liste von Funktionen nimmt und sie miteinander verkettet, den Rückgabewert jedes Schritts nimmt und ihn an die nächste Funktion in der Reihe weitergibt.
// hard to test function createGreeting(name, location, age) { let greeting; if (location === 'Mexico') { greeting = '!Hola'; } else { greeting = 'Hello'; } greeting += ' ' + name.toUpperCase() + '! '; greeting += 'You are ' + age + ' years old.'; return greeting; } // easy to test function getBeginning(location) { if (location === 'Mexico') { return 'Hola'; } else { return 'Hello'; } } function getMiddle(name) { return ' ' + name.toUpperCase() + '! '; } function getEnd(age) { return 'You are ' + age + ' years old.'; } function createGreeting(name, location, age) { return getBeginning(location) + getMiddle(name) + getEnd(age); }
Parameter nicht mutieren
In JavaScript werden Arrays und Objekte als Referenz und nicht als Wert übergeben, und sie sind änderbar. Das bedeutet, dass, wenn Sie ein Objekt oder ein Array als Parameter an eine Funktion übergeben, sowohl Ihr Code als auch die Funktion, an die Sie das Objekt oder Array übergeben haben, die Möglichkeit haben, dieselbe Instanz dieses Arrays oder Objekts im Speicher zu ändern. Das bedeutet, dass Sie beim Testen Ihres eigenen Codes darauf vertrauen müssen, dass keine der Funktionen, die Ihr Code aufruft, Ihre Objekte verändert. Jedes Mal, wenn Sie eine neue Stelle in Ihrem Code hinzufügen, die dasselbe Objekt ändert, wird es immer schwieriger, den Überblick darüber zu behalten, wie dieses Objekt aussehen soll, was das Testen erschwert.
Wenn Sie stattdessen eine Funktion haben, die ein Objekt oder Array akzeptiert, lassen Sie sie auf dieses Objekt oder Array so reagieren, als wäre es schreibgeschützt. Erstellen Sie ein neues Objekt oder Array im Code und fügen Sie je nach Bedarf Werte hinzu. Oder verwenden Sie Underscore oder Lodash, um das übergebene Objekt oder Array zu klonen, bevor Sie damit arbeiten. Verwenden Sie noch besser ein Tool wie Immutable.js, das schreibgeschützte Datenstrukturen erstellt.
// alters objects passed to it function upperCaseLocation(customerInfo) { customerInfo.location = customerInfo.location.toUpperCase(); return customerInfo; } // sends a new object back instead function upperCaseLocation(customerInfo) { return { name: customerInfo.name, location: customerInfo.location.toUpperCase(), age: customerInfo.age }; }
Schreiben Sie Ihre Tests vor Ihrem Code
Der Prozess des Schreibens von Komponententests vor dem zu testenden Code wird als testgetriebene Entwicklung (TDD) bezeichnet. Viele Entwickler finden TDD sehr hilfreich.
Indem Sie zuerst Ihre Tests schreiben, sind Sie gezwungen, über die API, die Sie verfügbar machen, aus der Perspektive eines Entwicklers nachzudenken, der sie nutzt. Es hilft auch sicherzustellen, dass Sie nur genug Code schreiben, um den Vertrag zu erfüllen, der durch Ihre Tests durchgesetzt wird, anstatt eine unnötig komplexe Lösung zu überarbeiten.
In der Praxis ist TDD eine Disziplin, die für alle Ihre Codeänderungen schwierig zu begehen sein kann. Aber wenn es sich lohnt, es zu versuchen, ist es eine großartige Möglichkeit, sicherzustellen, dass der gesamte Code testbar bleibt.
Einpacken
Wir alle wissen, dass es einige Fallstricke gibt, auf die man beim Schreiben und Testen komplexer JavaScript-Apps sehr leicht hereinfallen kann. Aber hoffentlich können wir mit diesen Tipps und der Erinnerung, unseren Code immer so einfach und funktional wie möglich zu halten, unsere Testabdeckung hoch und die Codekomplexität insgesamt niedrig halten!
- Die 10 häufigsten Fehler, die JavaScript-Entwickler machen
- The Need for Speed: A Toptal JavaScript Coding Challenge Retrospektive