Testen von Promise-Nebenwirkungen mit Async/Await

Testversprechen

Möglicherweise sind Sie schon einmal in Situationen geraten, in denen Sie asynchronen Code innerhalb eines Rückrufs eines Frameworks aufrufen und dessen Nebeneffekte testen müssen. Beispielsweise könnten Sie API-Aufrufe innerhalb einer React-Komponente durchführen. componentDidMount() Rückruf, der wiederum anruft setState() wenn die Anforderung abgeschlossen ist und Sie bestätigen möchten, dass sich die Komponente in einem bestimmten Zustand befindet. Dieser Artikel zeigt Techniken zum Testen solcher Szenarien.
Nehmen wir ein vereinfachtes Beispiel. Wir haben eine Klasse namens PromisesHaveFinishedIndicatorDer Konstruktor nimmt eine Liste von Promises auf. Wenn alle Promises eingelöst wurden, wird die Instanz finished Eigenschaft ist festgelegt auf true:

Klasse PromisesHaveFinishedIndicator { Konstruktor(Promises) { this.finished = false; Promise.all(Promises).then(() => { this.finished = true; }); } }

Ein guter Testfall würde darin bestehen, den Konstruktor mit mehreren Versprechen aufzurufen, deren Auflösungszeitpunkt wir steuern können, und Erwartungen an den Wert von this.finished wenn jedes Versprechen eingelöst wird.
Um die Auflösungszeitpunkte von Promises in Tests zu kontrollieren, verwenden wir Deferred Objekte, die die resolve und reject Methoden:

Klasse Deferred { Konstruktor() { this.promise = neues Promise((auflösen, ablehnen) => { this.auflösen = auflösen; this.ablehnen = ablehnen; }); } }

Damit können wir einen Test einrichten für PromisesHaveFinishedIndicator. Wir benutzen das ist Testframework in diesem Beispiel, aber die Technik kann auch auf andere Testframeworks angewendet werden:

test('setzt finished auf true, nachdem alle Promises eingelöst wurden', () => { const d1 = new Deferred(); const d2 = new Deferred(); const indicator = new PromisesHaveFinishedIndicator([d1.promise, d2.promise]); expect(indicator.finished).toBe(false); d2.resolve(); expect(indicator.finished).toBe(false); d1.resolve(); expect(indicator.finished).toBe(true); });

Dieser Test schlägt tatsächlich fehl, da Promise-Rückrufe asynchron sind. Alle Rückrufe in der Warteschlange werden also nach der letzten Anweisung dieses Tests ausgeführt, weil bis zum Abschluss ausführen Semantik. Mit anderen Worten, der Promise-Callback für die Promise.all Anruf: () => { this.finished = true; } wird ausgeführt, nachdem dieser Test bereits beendet wurde!
Jest (und andere Test-Frameworks) bietet eine Möglichkeit, mit Asynchronität umzugehen, indem verhindert wird, dass der Test nach der letzten Anweisung beendet wird. Wir müssten den bereitgestellten done Funktion, um dem Runner mitzuteilen, dass der Test beendet ist. Nun könnte man meinen, dass so etwas funktionieren würde:

test('setzt finished auf true, nachdem alle Promises eingelöst wurden', (done) => { const d1 = new Deferred(); const d2 = new Deferred(); const indicator = new PromisesHaveFinishedIndicator([d1.promise, d2.promise]); expect(indicator.finished).toBe(false); d2.resolve(); d2.then(() => { expect(indicator.finished).toBe(false); d1.resolve(); d1.then(() => { expect(indicator.finished).toBe(true); done(); }); }); });

Doch auch dies wird scheitern. Der Grund liegt in der Umsetzung Promise.all. Wenn resolve wird aufgerufen d1 (und d2 sowie), Promise.all plant einen Rückruf, der prüft, ob alle Versprechen eingelöst wurden. Wenn diese Prüfung „true“ ergibt, wird das vom Promise.all Anruf, der dann die Warteschlange einreiht () => { this.finished = true; } Rückruf. Dieser Rückruf befindet sich noch in der Warteschlange, wenn done heißt!
Nun stellt sich die Frage, wie wir den Rückruf durchführen, der this.finished zu true vor dem Anrufen ausführen done? Um diese Frage zu beantworten, müssen wir verstehen, wie Promise-Callbacks geplant werden, wenn Promises eingelöst oder abgelehnt werden. Jake Archibalds Artikel über Aufgaben, Mikroaufgaben, Warteschlangen und Zeitpläne geht genau auf dieses Thema ein und ich kann die Lektüre wärmstens empfehlen.
Zusammenfassend: Promise-Callbacks werden in die Microtask-Warteschlange gestellt und Callbacks von APIs wie setTimeout(fn) und setInterval(fn) werden in die Warteschlange der Makrotask eingereiht. Rückrufe in der Warteschlange der Mikrotask werden direkt nach dem Leeren des Stapels ausgeführt. Wenn eine Mikrotask eine andere Mikrotask plant, werden sie kontinuierlich aus der Warteschlange gezogen, bevor sie in die Warteschlange der Makrotask eingereiht werden.
Mit diesem Wissen können wir diesen Test bestehen, indem wir setTimeout statt then():

test('setzt finished auf true, nachdem alle Promises eingelöst wurden', (done) => { const d1 = new Deferred(); const d2 = new Deferred(); const indicator = new PromisesHaveFinishedIndicator([d1.promise, d2.promise]); expect(indicator.finished).toBe(false); d2.resolve(); setTimeout(() => { expect(indicator.finished).toBe(false); d1.resolve(); setTimeout(() => { expect(indicator.finished).toBe(true); done(); }, 0); }, 0); });

Der Grund dafür ist, dass bis zur zweiten setTimeout Rückrufläufe, wir wissen, dass diese Promise-Rückrufe ausgeführt wurden:

  • Der Rückruf innerhalb der Implementierung von Promise.all das prüft, ob alle Versprechen eingelöst wurden, und löst dann das zurückgegebene Versprechen ein.
  • Der Rückruf, der this.finished = true.

Mit einer Menge setTimeout(fn, 0) in unserem Code ist gelinde gesagt unansehnlich. Wir können dies mit dem neuen bereinigen async/await Syntax:

Funktion flushPromises() { return new Promise((resolve, reject) => setTimeout(resolve, 0)); } Test('setzt finished auf true, nachdem alle Promises eingelöst wurden', async () => { const d1 = new Deferred(); const d2 = new Deferred(); const indicator = new PromisesHaveFinishedIndicator([d1.promise, d2.promise]); expect(indicator.finished).toBe(false); d2.resolve(); await flushPromises(); expect(indicator.finished).toBe(false); d1.resolve(); await flushPromises(); expect(indicator.finished).toBe(true); });

Wenn Sie es besonders schick mögen, können Sie setImmediate statt setTimeout in einigen Umgebungen (Node.js). Es ist schneller als setTimeout läuft aber trotzdem nach Mikrotasks:

Funktion flushPromises() { returniere neues Promise(resolve => setImmediate(resolve)); }

Beim Schreiben von Tests mit Promises und Asynchronität ist es hilfreich zu verstehen, wie Callbacks geplant werden und welche Rolle die verschiedenen Warteschlangen in der Ereignisschleife spielen. Dieses Wissen ermöglicht es uns, den von uns geschriebenen asynchronen Code zu analysieren.