Sinon: Resolver promesas nativas de ES6 no activa devoluciones de llamada cuando se usan temporizadores falsos

Creado en 23 abr. 2015  ·  30Comentarios  ·  Fuente: sinonjs/sinon

En la siguiente prueba, esperaba que se invocara la devolución de llamada para la promesa resuelta mientras estaba dentro de la prueba. Aparentemente, las promesas nativas no invocan las devoluciones de llamada sincrónicamente, sino que las programan para que se llamen de una manera similar a setTimeout(callback, 0) . Sin embargo, en realidad no usa setTimeout, por lo que la implementación de temporizadores falsos de sinon no activa la devolución de llamada cuando se llama a 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"

Espero esta salida:

resolved
callback
test finished
teardown

En su lugar me sale esto:

resolved
test finished
teardown
callback

La devolución de llamada se invoca después de que finaliza la prueba, por lo que las afirmaciones basadas en lo que sucede dentro de la devolución de llamada fallan.

No importa si la promesa se resuelve antes o después de llamar a then() .

Comentario más útil

Lo único que necesita es esperar a que se ejecute la microtarea de promesa.
Así que el siguiente enfoque funciona perfecto:

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

Todos 30 comentarios

Me he encontrado con este problema también

No creo que esto se pueda resolver, ya que la especificación Promise no usa setTimeout , pero programa un nuevo trabajo para que se realice justo después del actual.

¿Has intentado devolver la promesa de la prueba? La mayoría de las bibliotecas de prueba admiten promesas ahora, y si la función it() devuelve una promesa, el ejecutor de la prueba esperará a que la promesa se resuelva o rechace antes de anotar la prueba como completada.

Sí, devolver una promesa de una prueba probablemente funcione. Pero al usar temporizadores falsos, esperaría poder completar la prueba antes de regresar de la función de prueba. Ese es un poco el punto.

Estoy bastante seguro de que esto se puede hacer reemplazando el objeto Promise, al igual que se reemplaza el objeto Date. Para poder hacer eso, probablemente necesitemos una implementación completa de la especificación de promesa, ya que no podremos confiar en la implementación nativa.

Veo el problema que ustedes están señalando aquí. Sin embargo, quería mencionar la situación específica con la que me encontré, ya que, según la especificación de la promesa y la documentación de sinon, me pareció que esto "debería" funcionar. En este caso, estoy devolviendo la promesa a la prueba, pero el uso de temporizadores falsos parece evitar que la promesa se resuelva.

Me pareció que ambas pruebas deberían funcionar, pero la primera pasa mientras que la segunda falla. Estoy usando chai con el complemento chai-as-promised con mocha y aparece el error "se excedió el tiempo de espera de 2000 ms. Asegúrese de que se llame a la devolución de llamada done() en esta prueba".

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

});

Preparé un proyecto de prueba rápida: https://github.com/JustinLivi/sinon-promises-test

Restaurar antes de completar la prueba parece ser una solución alternativa 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 solución alternativa que estamos usando es reemplazar la implementación de la promesa nativa con algo simple que depende de setTimeout, cuando se ejecutan las pruebas:

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

Sinon.JS simplemente podría hacer lo mismo al llamar a useFakeTimers.

@JustinLivi restaurar antes de completarlo lo hizo por mí :+1:

Entonces, ¿alguna solución o alternativa?
No puedo modificar mi aplicación y dejar de usar ES6 Promises, porque mis pruebas lo dicen :)

Tengo el problema, no con las promesas nativas sino con el polyfill es6-promise.
La solución clock.restore() de @JustinLivi soluciona el problema.

No tengo idea de por qué este problema ha persistido aquí durante tanto tiempo, pero no es un problema con Sinon. Usar Promises es esencialmente realizar una lógica asíncrona. Aún así, todas las pruebas aquí usan lógica síncrona (como menciona el OP). Entonces, aunque esté fingiendo el tiempo, aún confía en que Promise se ejecutará después de que finalice su función, lo que significa que necesita cambiar un poco su prueba. Esto generalmente se cubre en los documentos de la mayoría de los marcos de prueba ( aquí hay uno de Mocha ), pero lo abordaremos con algunos artículos que presentan recetas de prueba en el próximo sitio nuevo.

Entonces, cambiando un poco el ejemplo de @JustinLivi , terminamos con la siguiente prueba:

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 realidad, si está usando Mocha, tiene una versión de "atajo" del mismo código anterior cuando usa Promises, si solo devuelve la promesa directamente al marco de prueba:

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

    this.clock.tick( 10001 );

    return promise;
});

Esta voluntad

  1. Ejecute la función executor enviada como parámetro al constructor de Promise de forma síncrona (consulte la especificación ES2015 ), configurando efectivamente el nuevo tiempo de espera.
  2. Devolver la promesa construida.
  3. Marque el tiempo.
  4. Active la función de tiempo de espera agotado, marcando la promesa como resuelta
  5. process (o el navegador) acumulará un nuevo "tick", resolviendo la promesa y llamando a cualquier Thenables restante

Cerrando esto como un error.

@ fatso83 No veo cómo está logrando que las pruebas pasen de la manera que ha descrito. Agregué sus pruebas de ejemplo a mi repositorio de pruebas y sigo recibiendo timeout of 2000ms exceeded. Ensure the done() callback is being called in this test.

Consulte aquí: https://github.com/JustinLivi/sinon-promises-test/blob/master/test.js#L60
Además, por lo que puedo decir, esta prueba aún falla: https://github.com/JustinLivi/Sinon.JS/commit/de106e6db2f5cc076b7e3a78635bd9ae2b6be1c2

Editar: según el comentario de @fpirsch , ¿parece que es solo cuando se usa el polyfill de la promesa es6? ¿Alguien ha probado con alguna biblioteca alternativa como bluebird?

@fatso83 Esto NO es un problema return promise .
En mi caso, este es un problema Phantom (antiguo, sin promesas) + es6-promises polyfill + sinon.js. En los navegadores modernos, la promesa se resuelve y las pruebas funcionan bien, pero con la promesa polyfill, la promesa nunca se resuelve cuando se falsifican los temporizadores.

@JustinLivi y @fpirsch : este informe de error trataba sobre el uso de promesas _native_. Veo el proyecto de prueba de @JustinLivi usando es6-promise , así que no puedo responder por eso. Lo mismo ocurre con otros polyfills: ese sería otro problema. He probado esto con los nodos 5 y 6, que tienen soporte de promesa nativo.

Copie y pegue mi código de ejemplo de la publicación anterior y ejecútelo (con Mocha y Sinon preinstalados):

$ pbpaste > test.js

$ mocha test.js

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

  1 passing (12ms)

@fpirsch : nunca dije que fuera un problema return promise . Acabo de mencionar que podría reescribir la prueba en una forma más corta. Un consejo, no una solución. Pero proporcionó información valiosa: funciona en navegadores nativos, pero falla en la promesa polyfill. Eso es culpa de tu promesa lib, no de Sinon. Lo más probable es que su lib de promesa no almacene en caché setTimeout y amigo, y dependa de él para funcionar, por lo que se rompe. Implementé una solución para exactamente esto en la pinkyswear una vez, así que no es de extrañar.

Verificó la fuente de es6-promise y no almacena en caché las referencias a setTimeout , por lo que se verá afectado por cualquier cosa que haga Sinon. Pero no veo cómo ese programador es relevante aquí... También hay una lista enorme de otras opciones que el polyfill probará antes de recurrir al programador de tiempo de espera. Cualquier navegador que aterrice en ese respaldo debe ser antiguo :)

Pero de todos modos, lo que mencionan @fpirsch y @JustinLivi son problemas relacionados con la interoperabilidad entre Sinon y el polyfill. Y ese es otro tema. No veo cómo Sinon puede hacer mucho al respecto ATM (cualquier PR es más probable que termine en es6-polyfill ), pero si se trata de un problema de Sinon, siéntase libre de abrir un nuevo problema.

PD Veo que el consejo de @ropez fue usar promise-polyfill , y esa es de hecho nuestra recomendación para un polyfill mínimo también, pero por la razón opuesta a la dada. @mroderick parchó ese proyecto en enero (unos meses después del comentario de @ropez ) para almacenar en caché la referencia a setTimeout para que la promesa se resolviera _sin importar lo que estuviera haciendo sinon_ en la referencia global setTimeout . Ver taylorhakes/promise-polyfill#15 para más detalles.

@fatso83 gracias por la información. Desafortunadamente, guardar una referencia a setTimeout como lo hizo @mroderick para promise-polyfill no funciona en mi caso. Tampoco reemplazar es6-promise con promise-polyfill :-(
EDITAR: lo hace en una pila fantasma+mocha simple, pero no en nuestra pila completa con karma. Tan cansado de esto.

@ fatso83 Pongo un ejemplo aquí: faketimers
Después de todo, el problema puede estar relacionado con Karma... Intentaré profundizar más en esto.

@fpirsch : Creo que estás en algo. Tanto tú como @JustinLivi confían en el karma como corredor de pruebas. Parecen muy similares. PD: Eliminé mi comentario porque pensé que era totalmente superfluo, ya que Justin ya proporcionó un caso de prueba que mostraba el problema, pero su proyecto de ejemplo refuerza la hipótesis de que esto es una cuestión de Karma, ¡así que gracias!

Esto podría estar relacionado, pero no estoy seguro. ¿Por qué falla la tercera prueba a continuación? La falla específica es el tiempo de espera de prueba típico:

     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 La tercera prueba falla porque clock.tick procesa los ejecutores de forma síncrona y, por lo tanto, el .then(...) no se ejecuta debido a su naturaleza asíncrona :

No se debe llamar a onFulfilled o onRejected hasta que la pila de contexto de ejecución contenga solo código de plataforma.

@ fatso83 Desconozco la posición de los desarrolladores de lolex con respecto a las promesas encadenadas que agregan temporizadores. La limitación actual debe al menos ser detallada en la documentación.

Dependiendo de si esto debería ser compatible o no, este problema se puede reabrir (o se puede abrir uno nuevo) y depender de un nuevo problema en lolex .
También supongo que si se debe tener en cuenta este caso de uso (encadenar promesas que agregan temporizadores), esto conducirá a una modificación disruptiva de la API de lolex (AFAIK clock.tick tendrá que ser asíncrono).

@gautaz Ah tiene sentido ahora. ¡Gracias por la explicación!

lolex es una biblioteca de sincronización, y de todos modos no estoy seguro de cómo resolver el problema de @jasonkuhrt . No dude en proporcionar un PR, pero modificar clock.tick no es muy atractivo. Prefiero ver un método asíncrono adicional.

Nunca tuve muchos problemas con las promesas y el tictac del reloj, pero eso probablemente se debió a que había visto que eran sincrónicos y agregué tictacs de reloj adicionales en el medio.

@ fatso83 Tiene razón, agregar un tick asincrónico sería menos disruptivo (de hecho, no interrumpiría en absoluto).
Así que algo como asyncTick podría ser la opción adecuada. Lo investigaré si puedo hacer tiempo.

@gautaz solo tiene curiosidad si alguna vez tuvo la oportunidad de proporcionar un parche a lolex ?

@ fatso83 Hola, comencé una sucursal el año pasado por mi parte, pero por ahora nunca tuve tiempo para terminar el trabajo.
Tengo colegas que también han tenido problemas con el mismo problema, por lo que solo puedo esperar que el problema sea lo suficientemente grande como para brindarme más tiempo.

¿Algo cambió mientras tanto con respecto a la API de lolex en este punto en particular?

No debería haberlo hecho. Muy pocos cambios en el código base el último medio año. bastante estable

¿Alguna posibilidad de tener una solución simple aquí? Tener el mismo problema. Tengo dos promesas y son resueltas por setTimeout. Necesito verificar las cosas antes de cualquier resolución, antes de la segunda pero después de la primera resolución, y después de todas las resoluciones.

Lo único que necesita es esperar a que se ejecute la microtarea de promesa.
Así que el siguiente enfoque funciona perfecto:

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

El truco de @jakwuh funcionó para mí (Nodo 8, sin transpilación, promesas nativas), excepto que las devoluciones de llamada then se pospusieron por un tic.

Comentario actualizado : la solución en mi comentario original (a continuación) tiene problemas. A veces necesitaba tener múltiples llamadas consecutivas await Promise.resolve() para realmente vaciar todo. Aquí hay algo que parece funcionar un poco mejor:

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

Comentario original [ADVERTENCIA: no funciona tan bien como el código anterior.]

Agregar una brecha asincrónica adicional al ayudante tick me solucionó el problema:

```js
const tick = asíncrono (ms) => {
reloj.tick(ms);
esperar Promesa.resolve();
};

Para las personas interesadas en el trabajo para mejorar Sinon en esta área (en realidad, su proyecto hermano lolex ), consulte las discusiones aquí:

¿Fue útil esta página
0 / 5 - 0 calificaciones