Sinon: La résolution des promesses ES6 natives ne déclenche pas de rappels lors de l'utilisation de fausses minuteries

Créé le 23 avr. 2015  ·  30Commentaires  ·  Source: sinonjs/sinon

Dans le test suivant, je m'attendais à ce que le rappel de la promesse résolue soit invoqué pendant le test. Apparemment, les promesses natives n'invoquent pas les rappels de manière synchrone, mais les planifient pour qu'elles soient appelées d'une manière similaire à setTimeout(callback, 0) . Cependant, il n'utilise pas réellement setTimeout, donc l'implémentation de faux minuteurs par sinon ne déclenche pas le rappel lors de l'appel tick() .

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"

J'attends cette sortie :

resolved
callback
test finished
teardown

A la place j'obtiens ceci :

resolved
test finished
teardown
callback

Le rappel est appelé une fois le test terminé, donc les assertions basées sur ce qui se passe à l'intérieur du rappel échouent.

Peu importe que la promesse soit résolue avant ou après l'appel then() .

Commentaire le plus utile

La seule chose dont vous avez besoin est d'attendre que la microtâche promise s'exécute.
L'approche suivante fonctionne donc parfaitement :

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);
  });

Tous les 30 commentaires

J'ai aussi rencontré ce problème

Je ne pense pas que cela puisse être résolu, car la spécification Promise n'utilise pas setTimeout , mais planifie un nouveau travail à faire juste après celui en cours.

Avez-vous essayé de retourner la promesse du test ? La plupart des bibliothèques de test prennent désormais en charge les promesses, et si la fonction it() renvoie une promesse, le testeur attendra que la promesse soit résolue ou rejetée avant de noter le test comme terminé.

Oui, renvoyer une promesse à partir d'un test fonctionne probablement. Mais lors de l'utilisation de fausses minuteries, je m'attendrais à pouvoir participer au test avant de revenir de la fonction de test. C'est un peu tout le problème.

Je suis presque sûr que cela peut être fait en remplaçant l'objet Promise, tout comme l'objet Date est remplacé. Pour pouvoir le faire, nous avons probablement besoin d'une implémentation complète de la spécification promise, car nous ne pourrons pas nous fier à l'implémentation native.

Je vois le problème que vous signalez ici. Je voulais appeler la situation spécifique que j'ai rencontrée cependant, car sur la base à la fois de la spécification de la promesse et de la documentation sinon, il m'a semblé que cela "devrait" fonctionner. Dans ce cas, je renvoie la promesse au test, mais l'utilisation de fausses minuteries semble empêcher la résolution de la promesse.

Il me semblait que ces deux tests devraient fonctionner, mais le premier réussit tandis que le second échoue. J'utilise chai avec le plugin chai-as-promised avec moka et j'obtiens l'erreur "timeout de 2000 ms dépassé. Assurez-vous que le rappel done() est appelé dans ce test."

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;
    });

});

J'ai monté un projet de test rapide : https://github.com/JustinLivi/sinon-promises-test

La restauration avant la fin du test semble être une solution de contournement viable :

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;
    });

});

La solution de contournement que nous utilisons consiste à remplacer l'implémentation de la promesse native par quelque chose de simple qui dépend de setTimeout, lors de l'exécution des tests :

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

Sinon.JS pourrait simplement faire la même chose lors de l'appel de useFakeTimers.

@JustinLivi la restauration avant la fin l'a fait pour moi :+1:

Alors, une solution ou un contournement ?
Je ne peux pas modifier mon application et arrêter d'utiliser ES6 Promises, car mes tests le disent :)

J'ai le problème, pas avec les promesses natives mais avec le polyfill es6-promise.
La solution clock.restore() de @JustinLivi résout le problème.

Aucune idée pourquoi ce problème persiste ici depuis si longtemps, mais ce n'est pas un problème avec Sinon. L'utilisation de Promises exécute essentiellement une logique asynchrone. Pourtant, tous les tests ici utilisent la logique synchrone (comme le mentionne en effet l'OP). Ainsi, même si vous simulez le temps, vous comptez toujours sur la promesse pour s'exécuter après la fin de votre fonction, ce qui signifie que vous devez modifier un peu votre test. Ceci est généralement couvert dans la documentation de la plupart des frameworks de test ( en voici un de Mocha ), mais nous aborderons ce problème avec quelques articles présentant des recettes de test dans le nouveau site à venir.

Donc en changeant un peu l'exemple de @JustinLivi on se retrouve avec le test suivant :

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
    });

});

En fait, si vous utilisez Mocha, il a une version "raccourci" du même code ci-dessus lors de l'utilisation de Promises, si vous renvoyez simplement la promesse directement au framework de test :

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

    this.clock.tick( 10001 );

    return promise;
});

Cette volonté

  1. Exécutez la fonction executor envoyée en tant que paramètre au constructeur Promise de manière synchrone (voir la spécification ES2015 ), en configurant efficacement le nouveau délai d'attente.
  2. Renvoie la promesse construite.
  3. Cochez l'heure.
  4. Déclenchez la fonction de temporisation, marquant la promesse comme résolue
  5. process (ou le navigateur) empilera un nouveau "tic", résolvant la promesse et appelant tout Thenables restant

Fermer ceci comme un non-bogue.

@ fatso83 Je ne vois pas comment vous faites passer les tests comme vous l'avez décrit. J'ai ajouté vos exemples de tests à mon référentiel de tests et j'obtiens toujours timeout of 2000ms exceeded. Ensure the done() callback is being called in this test.

Voir ici : https://github.com/JustinLivi/sinon-promises-test/blob/master/test.js#L60
De plus, pour autant que je sache, ce test échoue toujours : https://github.com/JustinLivi/Sinon.JS/commit/de106e6db2f5cc076b7e3a78635bd9ae2b6be1c2

Edit : d'après le commentaire de @fpirsch , il semble que ce soit uniquement lors de l'utilisation du polyfill de la promesse es6 ? Quelqu'un a-t-il essayé avec des bibliothèques alternatives telles que bluebird?

@ fatso83 Ce n'est PAS un problème return promise .
Dans mon cas, il s'agit d'un problème Phantom (ancien, sans promesses) + es6-promises polyfill + problème sinon.js. Dans les navigateurs modernes, la promesse est résolue et les tests fonctionnent correctement, mais avec la promesse polyfill, la promesse ne se résout jamais lorsque les minuteurs sont truqués.

@JustinLivi et @fpirsch : ce rapport de bogue concernait l'utilisation de promesses _natives_. Je vois le projet de test de @JustinLivi utilisant es6-promise , donc je ne peux pas garantir cela. Il en va de même pour les autres polyfills : ce serait un autre problème. J'ai testé cela avec les nœuds 5 et 6, qui prennent en charge les promesses natives.

Copiez-collez mon exemple de code du post précédent et exécutez-le (avec Mocha et Sinon pré-installés):

$ pbpaste > test.js

$ mocha test.js

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

  1 passing (12ms)

@fpirsch : Je n'ai jamais dit que c'était un problème return promise . Je viens de mentionner qu'il pourrait réécrire le test sous une forme plus courte. Un conseil, pas une solution. Mais vous avez fourni des informations précieuses : cela fonctionne dans les navigateurs natifs, mais échoue dans le polyfill promis. C'est une faute de votre promesse lib, pas Sinon. Votre promesse lib ne met probablement pas en cache setTimeout et son ami, et s'appuie sur elle pour fonctionner, ce qui la casse. J'ai implémenté un correctif pour exactement cela dans la pinkyswear une fois, donc pas étonnant.

Vérifié la source de es6-promise et il ne met pas en cache les références à setTimeout , il sera donc affecté par tout ce qui se passe autrement. Mais je ne vois pas en quoi ce planificateur est pertinent ici ... Il existe également une énorme liste d'autres options que le polyfill essaiera avant de se rabattre sur le planificateur de délai d'attente. Tout navigateur atterrissant sur cette solution de repli doit être ancien :)

Mais quoi qu'il en soit, ce que @fpirsch et @JustinLivi mentionnent sont des problèmes d'interopérabilité entre Sinon et le polyfill. Et c'est un autre problème. Ne voyant pas comment Sinon peut faire grand-chose à ce sujet ATM (tout PR est plus susceptible de se retrouver dans es6-polyfill ), mais s'il s'agit d'un problème Sinon, n'hésitez pas à ouvrir un nouveau problème.

PS Je vois que le conseil de @ropez était d'utiliser promise-polyfill , et c'est en effet notre recommandation pour un polyfill minimal aussi, mais pour la raison opposée à celle donnée. @mroderick a corrigé ce projet en janvier (quelques mois après le commentaire de @ropez ) pour mettre en cache la référence à setTimeout afin que la promesse résolve _peu importe ce que faisait sinon_ la référence globale setTimeout . Voir taylorhakes/promise-polyfill#15 pour plus de détails.

@ fatso83 merci pour l'info. Malheureusement, enregistrer une référence à setTimeout comme @mroderick l'a fait pour promise-polyfill ne fonctionne pas dans mon cas. Remplacer es6-promise par promise-polyfill non plus :-(
EDIT : c'est le cas dans une simple pile fantôme + moka, mais pas dans notre pile complète avec karma. Tellement fatigué de ça.

@ fatso83 J'ai mis un exemple ici : faketimers
Le problème vient peut-être du Karma après tout... Je vais essayer d'approfondir ça.

@fpirsch : Je pense que vous êtes sur quelque chose. Vous et @JustinLivi comptez sur le karma en tant que test runner. Ils semblent très similaires. PS J'ai supprimé mon commentaire car je pensais qu'il était totalement superflu, car Justin a déjà fourni un cas de test qui montrait le problème, mais votre projet d'exemple renforce l'hypothèse selon laquelle il s'agit d'un truc Karma, alors merci !

C'est peut-être lié mais je ne suis pas sûr. Pourquoi le troisième test ci-dessous échoue-t-il ? L'échec spécifique est le délai d'attente de test typique :

     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 Le troisième test échoue car clock.tick traite les exécuteurs de manière synchrone et donc le .then(...) n'est pas exécuté en raison de sa nature asynchrone :

onFulfilled ou onRejected ne doivent pas être appelés tant que la pile de contexte d'exécution ne contient que du code de plate-forme.

@ fatso83 Je ne connais pas la position des développeurs lolex concernant les promesses enchaînées qui ajoutent des minuteries. La limitation actuelle devrait au moins être détaillée dans la documentation.

Selon que cela doit être pris en charge ou non, ce problème peut être rouvert (ou un nouveau peut être ouvert) et dépendre d'un nouveau problème dans lolex .
Je suppose également que si ce cas d'utilisation (chaîner des promesses qui ajoutent des minuteries) doit être pris en compte, cela conduira à une modification perturbatrice de l'API lolex (AFAIK clock.tick devra être asynchrone).

@gautaz Ah a du sens maintenant. Merci d'avoir expliqué !

lolex est une bibliothèque de synchronisation, et je ne suis pas tout à fait sûr de savoir comment résoudre le problème de @jasonkuhrt de toute façon. N'hésitez pas à fournir un PR, mais modifier clock.tick n'est pas très attrayant. Je préférerais voir une méthode asynchrone supplémentaire.

Je n'ai jamais eu beaucoup de problèmes avec les promesses et le tic-tac, mais c'était probablement parce que j'avais vu qu'elles étaient synchrones et que j'avais ajouté des tic-tac supplémentaires entre les deux.

@ fatso83 Vous avez raison, ajouter un tick asynchrone serait moins perturbant (en fait pas du tout perturbant).
Donc, quelque chose comme asyncTick pourrait être la bonne solution. Je vais me renseigner si j'ai le temps.

@gautaz juste curieux de savoir si vous avez déjà fourni un patch à lolex ?

@fatso83 Salut, j'ai démarré une succursale l'année dernière de mon côté mais je n'ai jamais eu pour l'instant le temps de terminer le travail.
J'ai des collègues qui ont également été touchés par le même problème, alors je ne peux qu'espérer que le problème devienne suffisamment important pour me donner plus de temps.

Quelque chose a-t-il changé entre temps concernant l'API lolex sur ce point particulier ?

Ça n'aurait pas dû. Très peu de changements dans la base de code au cours du dernier semestre. Assez stable.

Des chances d'avoir une solution de contournement simple ici? Avoir le même problème. J'ai deux promesses et elles sont résolues par setTimeout. J'ai besoin de vérifier des choses avant toute résolution, avant la seconde mais après la première résolution, et après toutes les résolutions.

La seule chose dont vous avez besoin est d'attendre que la microtâche promise s'exécute.
L'approche suivante fonctionne donc parfaitement :

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);
  });

L'astuce de @jakwuh a fonctionné pour moi (nœud 8, pas de transpilation, promesses natives), sauf que les rappels then ont été reportés d'un tick.

Commentaire mis à jour : La solution dans mon commentaire original (ci-dessous) a des problèmes. Parfois, j'avais besoin de plusieurs appels await Promise.resolve() consécutifs pour tout vider. Voici quelque chose qui semble fonctionner un peu mieux :

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();
});

Commentaire d'origine [ATTENTION : ne fonctionne pas aussi bien que le code ci-dessus.]

L'ajout d'un espace asynchrone supplémentaire à l'assistant tick a résolu le problème pour moi :

```js
const tick = asynchrone (ms) => {
horloge.tick(ms);
attendre Promise.resolve();
} ;

Pour les personnes intéressées par le travail d'amélioration de Sinon dans ce domaine (en fait, son projet frère lolex ), consultez les discussions ici :

Cette page vous a été utile?
0 / 5 - 0 notes