Sinon: Das Auflösen nativer ES6-Versprechen löst keine Rückrufe aus, wenn gefälschte Timer verwendet werden

Erstellt am 23. Apr. 2015  ·  30Kommentare  ·  Quelle: sinonjs/sinon

Im folgenden Test habe ich erwartet, dass der Rückruf für das aufgelöste Promise während des Tests aufgerufen wird. Anscheinend rufen native Promises Rückrufe nicht synchron auf, sondern planen sie so, dass sie auf ähnliche Weise aufgerufen werden wie setTimeout(callback, 0) . Allerdings wird setTimeout nicht wirklich verwendet, sodass sinons Implementierung gefälschter Timer den Rückruf nicht auslöst, wenn tick() aufgerufen wird.

describe 'Promise', ->
  beforeEach ->
    <strong i="8">@clock</strong> = sinon.useFakeTimers()
  afterEach ->
    @clock.restore()
    console.log 'teardown'

  it "should invoke callback", ->
    p = new Promise (resolve, reject) ->
      console.log 'resolving'
      resolve(42)
      console.log 'resolved'
    p.then ->
      console.log 'callback'
    @clock.tick()
    console.log "test finished"

Ich erwarte diese Ausgabe:

resolved
callback
test finished
teardown

Stattdessen bekomme ich das:

resolved
test finished
teardown
callback

Der Rückruf wird aufgerufen, nachdem der Test abgeschlossen ist, sodass Assertionen, die darauf basieren, was innerhalb des Rückrufs passiert, fehlschlagen.

Dabei spielt es keine Rolle, ob das Promise vor oder nach dem Aufruf then() aufgelöst wird.

Hilfreichster Kommentar

Das einzige, was Sie brauchen, ist zu warten, bis die Promise-Microtask ausgeführt wird.
Der folgende Ansatz funktioniert also perfekt:

const tick = async (ms) => {
  clock.tick(ms);
};

it("imitates promise fulfill when called once", async () => {
    new Promise(resolve => setTimeout(resolve, 400)).then(callback);
    expect(callback.callCount).to.eql(0);

    await tick(200);
    expect(callback.callCount).to.eql(0);

    await tick(200);
    expect(callback.callCount).to.eql(1);
  });

Alle 30 Kommentare

Ich bin auch auf dieses Problem gestoßen

Ich glaube nicht, dass dies gelöst werden kann, da die Promise-Spezifikation nicht setTimeout verwendet, sondern einen neuen Job plant, der direkt nach dem aktuellen erledigt werden soll.

Haben Sie versucht, das Versprechen aus dem Test zurückzugeben? Die meisten Testbibliotheken unterstützen jetzt Promises, und wenn die it() -Funktion ein Promise zurückgibt, wartet der Testläufer darauf, dass das Promise aufgelöst oder abgelehnt wird, bevor er den Test als erledigt vermerkt.

Ja, das Zurückgeben eines Versprechens aus einem Test funktioniert wahrscheinlich. Aber wenn ich gefälschte Timer verwende, würde ich erwarten, dass ich den Test bestehen kann, bevor ich von der Testfunktion zurückkehre. Das ist irgendwie der springende Punkt.

Ich bin mir ziemlich sicher, dass dies durch Ersetzen des Promise-Objekts erreicht werden kann, genau wie das Date-Objekt ersetzt wird. Dazu benötigen wir wahrscheinlich eine vollständige Implementierung der Promise-Spezifikation, da wir uns nicht auf die native Implementierung verlassen können.

Ich sehe das Problem, auf das Sie hier hinweisen. Ich wollte jedoch auf die spezifische Situation hinweisen, auf die ich gestoßen bin, da es mir auf der Grundlage sowohl der Promise-Spezifikation als auch der Sinon-Dokumentation schien, dass dies funktionieren "sollte". In diesem Fall gebe ich das Versprechen an den Test zurück, aber die Verwendung gefälschter Timer scheint zu verhindern, dass das Versprechen jemals aufgelöst wird.

Es schien mir, dass beide Tests funktionieren sollten, aber der erste besteht, während der zweite fehlschlägt. Ich verwende Chai mit dem Chai-as-Promised-Plugin mit Mocha und erhalte die Fehlermeldung „Timeout von 2000 ms überschritten. Stellen Sie sicher, dass der done()-Callback in diesem Test aufgerufen wird.“

describe('chai as promised', function() {

    it('should resolve a promise', function() {
        var promise = new Promise(function( resolve ) {
            setTimeout( resolve, 1000 );
        });
        return expect( promise ).to.have.been.fulfilled;
    });

});

describe('sinon.useFakeTimers()', function() {

    before(function() {
        this.clock = sinon.useFakeTimers();
    });

    after(function() {
        this.clock.restore();
    });

    it('should resolve a promise after ticking', function() {

        var promise = new Promise(function( resolve ) {
            setTimeout( resolve, 1000 );
        });

        this.clock.tick( 1001 );

        return expect( promise ).to.have.been.fulfilled;
    });

});

Ich habe ein schnelles Testprojekt zusammengestellt: https://github.com/JustinLivi/sinon-promises-test

Das Wiederherstellen vor Abschluss des Tests scheint eine praktikable Problemumgehung zu sein:

describe('sinon.useFakeTimers()', function() {

    before(function() {
        this.clock = sinon.useFakeTimers();
    });

    it('should resolve a promise after ticking', function() {

        var promise = new Promise(function( resolve ) {
            setTimeout( resolve, 1000 );
        });

        this.clock.tick( 1001 );
        this.clock.restore();

        return expect( promise ).to.have.been.fulfilled;
    });

});

Die Problemumgehung, die wir verwenden, besteht darin, die native Promise-Implementierung beim Ausführen von Tests durch etwas Einfaches zu ersetzen, das von setTimeout abhängt:

window.Promise = require('promise-polyfill')

Sinon.JS könnte einfach dasselbe tun, wenn es useFakeTimers aufruft.

@JustinLivi Wiederherstellung vor Fertigstellung hat es für mich getan :+1:

Also eine Lösung oder Workaround?
Ich kann meine App nicht ändern und ES6 Promises nicht mehr verwenden, da meine Tests dies besagen :)

Ich habe das Problem nicht mit native Promises sondern mit dem es6-Promise Polyfill.
Die clock.restore() -Lösung von @JustinLivi behebt das Problem.

Keine Ahnung, warum dieses Problem hier so lange verweilt, aber das ist kein Problem mit Sinon. Die Verwendung von Promises führt im Wesentlichen eine asynchrone Logik aus. Dennoch verwenden alle Tests hier synchrone Logik (wie das OP tatsächlich erwähnt). Obwohl Sie also die Zeit vortäuschen, verlassen Sie sich immer noch auf das Promise, um es auszuführen, nachdem Ihre Funktion beendet ist, was bedeutet, dass Sie Ihren Test ein wenig ändern müssen. Dies wird normalerweise in den Dokumenten der meisten Test-Frameworks behandelt ( hier ist eines von Mocha ), aber wir werden dies mit einigen Artikeln mit Testrezepten auf der kommenden neuen Website ansprechen.

Wenn wir das Beispiel von @JustinLivi ein wenig ändern, erhalten wir am Ende den folgenden Test:

var sinon = require('sinon');

describe('sinon.useFakeTimers()', function() {

    before(function() {
        this.clock = sinon.useFakeTimers();
    });

    it('should resolve a promise after ticking', function(done) {

        var promise = new Promise(function( resolve ) {
            setTimeout( resolve, 10000 );
        });

        this.clock.tick( 10001 );

        promise
            .then(done) // call done when the promise completes
            .catch(done); // catch any accidental errors
    });

});

Wenn Sie Mocha verwenden, hat es tatsächlich eine "Shortcut" -Version des gleichen Codes oben, wenn Sie Promises verwenden, wenn Sie das Promise einfach direkt an das Testframework zurückgeben:

it('should resolve a promise after ticking', function() {
    var promise = new Promise(function( resolve ) {
        setTimeout( resolve, 10000 );
    });

    this.clock.tick( 10001 );

    return promise;
});

Dieser Wille

  1. Führen Sie die als Parameter an den Promise-Konstruktor gesendete executor -Funktion synchron aus (siehe ES2015-Spezifikation ) und richten Sie effektiv das neue Timeout ein.
  2. Geben Sie das konstruierte Versprechen zurück.
  3. Kreuzen Sie die Zeit an.
  4. Lösen Sie die Zeitüberschreitungsfunktion aus und markieren Sie das Versprechen als gelöst
  5. process (oder der Browser) stapelt ein neues "Tick", löst das Promise auf und ruft alle verbleibenden Thenables

Schließen Sie dies als Nicht-Fehler.

@fatso83 Ich verstehe nicht, wie Sie die Tests so bestehen lassen, wie Sie es beschrieben haben. Ich habe Ihre Beispieltests zu meinem Test-Repository hinzugefügt und erhalte immer noch timeout of 2000ms exceeded. Ensure the done() callback is being called in this test.

Siehe hier: https://github.com/JustinLivi/sinon-promises-test/blob/master/test.js#L60
Außerdem schlägt dieser Test, soweit ich das beurteilen kann, immer noch fehl: https://github.com/JustinLivi/Sinon.JS/commit/de106e6db2f5cc076b7e3a78635bd9ae2b6be1c2

Bearbeiten: Basierend auf dem Kommentar von @fpirsch scheint es nur bei Verwendung des es6-Versprechens Polyfill zu sein? Hat jemand mit alternativen Bibliotheken wie Bluebird versucht?

@fatso83 Dies ist KEIN return promise -Problem.
In meinem Fall ist dies ein Problem mit Phantom (alt, ohne Versprechungen) + es6-promises polyfill + sinon.js. In modernen Browsern wird das Promise aufgelöst und die Tests laufen gut, aber mit dem Promise-Polyfill wird das Promise nie aufgelöst, wenn die Timer gefälscht sind.

@JustinLivi und @fpirsch : In diesem Fehlerbericht ging es um die Verwendung von _nativen_ Promises. Ich sehe das Testprojekt von @JustinLivi mit es6-promise , daher kann ich dafür nicht bürgen. Gleiches gilt für andere Polyfills: Das wäre ein anderes Problem. Ich habe dies mit Node 5 und 6 getestet, die native Promise-Unterstützung bieten.

Kopieren Sie meinen Beispielcode aus dem vorherigen Beitrag und führen Sie ihn aus (mit Mocha und Sinon vorinstalliert):

$ pbpaste > test.js

$ mocha test.js

  sinon.useFakeTimers()
    ✓ should resolve a promise after ticking

  1 passing (12ms)

@fpirsch : Ich habe nie gesagt, dass es sich um ein return promise -Problem handelt. Ich habe gerade erwähnt, dass er den Test in kürzerer Form umschreiben könnte. Ein Tipp, keine Lösung. Aber Sie haben einige wertvolle Informationen bereitgestellt: Es funktioniert in nativen Browsern, scheitert aber an der versprechenden Polyfill. Das ist ein Fehler deines Versprechens, nicht Sinon. Ihre Promise-Bibliothek speichert höchstwahrscheinlich setTimeout und Freund nicht im Cache und verlässt sich darauf, um zu funktionieren, und bricht daher. Ich habe genau dafür einmal einen Fix in der pinkyswear lib implementiert, also kein Wunder.

Die Quelle von es6-promise wurde überprüft und es werden keine Verweise auf setTimeout zwischengespeichert , sodass es von allem, was sinon tut, beeinflusst wird. Aber ich verstehe nicht, wie dieser Scheduler hier relevant ist ... Es gibt auch eine riesige Liste anderer Optionen, die das Polyfill ausprobieren wird, bevor es auf den Timeout-Scheduler zurückgreift. Jeder Browser, der auf diesem Fallback landet, muss uralt sein :)

Aber wie auch immer, was @fpirsch und @JustinLivi erwähnen, sind Probleme bezüglich der Interoperabilität zwischen Sinon und dem Polyfill. Und das ist ein anderes Thema. Ich sehe nicht, wie Sinon viel dagegen tun kann ATM (Jede PR endet wahrscheinlicher in es6-polyfill ), aber wenn dies ein Sinon-Problem ist, können Sie gerne ein neues Problem eröffnen.

PS Ich sehe, der Tipp von @ropez war, promise-polyfill zu verwenden, und das ist in der Tat auch unsere Empfehlung für eine minimale Polyfill, aber aus dem entgegengesetzten Grund des angegebenen. @mroderick hat dieses Projekt im Januar gepatcht (ein paar Monate nach dem @ropez- Kommentar), um die Referenz auf setTimeout , damit das Versprechen _egal was sinon tat_ zur globalen setTimeout -Referenz auflösen würde. Siehe taylorhakes/promise-polyfill#15 für Details.

@fatso83 Danke für die Info. Leider funktioniert das Speichern eines Verweises auf setTimeout wie @mroderick für promise-polyfill in meinem Fall nicht. Ersetzen Sie es6-promise auch nicht durch promise-polyfill :-(
EDIT: Es funktioniert in einem einfachen Phantom + Mokka-Stack, aber nicht in unserem vollständigen Stack mit Karma. So müde davon.

@fatso83 Ich habe hier ein Beispiel aufgestellt: faketimers
Das Problem könnte doch Karma sein ... Ich werde versuchen, tiefer darauf einzugehen.

@fpirsch : Ich glaube, du bist auf etwas gefasst. Sowohl Sie als auch @JustinLivi verlassen sich als Testläufer auf Karma. Sie scheinen sehr ähnlich zu sein. PS Ich habe meinen Kommentar gelöscht, da ich ihn für völlig überflüssig hielt, da Justin bereits einen Testfall geliefert hat, der das Problem zeigte, aber Ihr Beispielprojekt stärkt die Hypothese, dass dies eine Karma-Sache ist, also danke!

Das könnte zusammenhängen, bin mir aber nicht sicher. Warum schlägt der dritte Test unten fehl? Der spezifische Fehler ist das typische Test-Timeout:

     Error: timeout of 2000ms exceeded. Ensure the done() callback is being called in this test.
describe.only('working', () => {
  let clock;
  beforeEach(() => { clock = Sinon.useFakeTimers(); })
  afterEach(() => { clock.restore() })

  const delay = (ms) => (
    new Promise((resolve/* , reject */) => {
      setTimeout(resolve, ms)
    })
  );

  it('1 OK', () => {
    const promise = new Promise((resolve) => {
      setTimeout(resolve, 100)
    })
    clock.tick(200)
    return promise
  })

  it('2 OK', () => {
    const promise = delay(100)
    clock.tick(200)
    return promise
  })

  it('3 FAIL', () => {
    const promise = delay(50).then(() => delay(50))
    clock.tick(200)
    return promise
  })
})

@jasonkuhrt Der dritte Test schlägt fehl, weil clock.tick Executoren synchron verarbeitet und daher .then(...) aufgrund seiner asynchronen Natur nicht innerhalb ausgeführt wird:

onFulfilled oder onRejected dürfen nicht aufgerufen werden, bis der Ausführungskontextstapel nur Plattformcode enthält.

@ fatso83 Ich kenne die Position von Lolex- Entwicklern in Bezug auf verkettete Versprechen, die Timer hinzufügen, nicht. Die Strombegrenzung sollte zumindest detailliert in der Dokumentation aufgeführt werden.

Je nachdem, ob dies unterstützt werden soll oder nicht, kann dieses Problem erneut geöffnet werden (oder es kann ein neues geöffnet werden) und von einem neuen Problem in lolex abhängen .
Ich vermute auch, dass, wenn dieser Anwendungsfall (Verkettung von Versprechungen, die Timer hinzufügen) berücksichtigt werden muss, dies zu einer störenden Lolex- API-Modifikation führen wird ( AFAIK clock.tick muss asynchron sein).

@gautaz Ah macht jetzt Sinn. Danke fürs Erklären!

lolex ist eine Synchronisierungsbibliothek, und ich bin mir sowieso nicht ganz sicher, wie ich das Problem von @jasonkuhrt lösen soll. Fühlen Sie sich frei, eine PR bereitzustellen, aber das Ändern clock.tick ist nicht sehr attraktiv. Ich würde lieber eine zusätzliche asynchrone Methode sehen.

Ich hatte nie große Probleme mit Versprechungen und dem Ticken der Uhr, aber das lag wahrscheinlich daran, dass ich gesehen hatte, dass sie synchron waren, und dazwischen zusätzliche Uhrticks hinzugefügt hatte.

@fatso83 Du hast Recht, das Hinzufügen eines asynchronen tick wäre weniger störend (tatsächlich überhaupt nicht störend).
Also könnte etwas wie asyncTick das Richtige sein. Ich werde mich darum kümmern, wenn ich Zeit finde.

@gautaz nur neugierig, ob Sie jemals dazu gekommen sind, einen Patch für lolex bereitzustellen?

@fatso83 Hallo, ich habe letztes Jahr einen Zweig auf meiner Seite gestartet, aber bisher nie die Zeit gefunden, den Job zu beenden.
Ich habe Kollegen, die ebenfalls über dasselbe Problem gestolpert sind, also kann ich nur hoffen, dass das Problem groß genug wird, um mir zusätzliche Zeit zu verschaffen.

Hat sich in der Zwischenzeit bezüglich der lolex API an diesem speziellen Punkt etwas geändert?

Es hätte nicht sein sollen. Sehr wenig Änderungen in der Codebasis im letzten halben Jahr. Ziemlich stabil.

Irgendwelche Möglichkeiten, hier eine einfache Problemumgehung zu haben? Habe das gleiche Problem. Ich habe zwei Versprechen und sie werden von setTimeout aufgelöst. Ich muss Dinge vor jeder Auflösung überprüfen, vor der zweiten, aber nach der ersten Auflösung und nach allen Auflösungen.

Das einzige, was Sie brauchen, ist zu warten, bis die Promise-Microtask ausgeführt wird.
Der folgende Ansatz funktioniert also perfekt:

const tick = async (ms) => {
  clock.tick(ms);
};

it("imitates promise fulfill when called once", async () => {
    new Promise(resolve => setTimeout(resolve, 400)).then(callback);
    expect(callback.callCount).to.eql(0);

    await tick(200);
    expect(callback.callCount).to.eql(0);

    await tick(200);
    expect(callback.callCount).to.eql(1);
  });

Der Trick von @jakwuh hat bei mir funktioniert (Knoten 8, keine Transpilation, native Promises), außer dass die then Callbacks um ein Häkchen verschoben wurden.

Aktualisierter Kommentar : Die Lösung in meinem ursprünglichen Kommentar (unten) hat Probleme. Manchmal musste ich mehrere aufeinanderfolgende await Promise.resolve() Anrufe tätigen, um tatsächlich alles zu löschen. Hier scheint etwas besser zu funktionieren:

beforeEach(function() {
    const originalSetImmediate = setImmediate;
    this.clock = sinon.useFakeTimers();
    this.tickAsync = async ms => {
        this.clock.tick(ms);
        await new Promise(resolve => originalSetImmediate(resolve));
    }
});

afterEach(function() {
    this.clock.restore();
});

Ursprünglicher Kommentar [WARNUNG: Funktioniert nicht so gut wie der obige Code.]

Das Hinzufügen einer zusätzlichen asynchronen Lücke zum Helfer tick hat das Problem für mich behoben:

```js
const tick = async (ms) => {
clock.tick (ms);
Warte auf Promise.resolve();
};

Für Leute, die daran interessiert sind, Sinon in diesem Bereich zu verbessern (eigentlich sein Schwesterprojekt lolex ), sehen Sie sich die Diskussionen hier an:

War diese Seite hilfreich?
0 / 5 - 0 Bewertungen