Sinon: A resolução de promessas nativas do ES6 não aciona retornos de chamada ao usar temporizadores falsos

Criado em 23 abr. 2015  ·  30Comentários  ·  Fonte: sinonjs/sinon

No teste a seguir, eu esperava que o retorno de chamada para a promessa resolvida fosse invocado enquanto estiver dentro do teste. Aparentemente, as promessas nativas não invocam retornos de chamada de forma síncrona, mas os agenda para serem chamados de maneira semelhante a setTimeout(callback, 0) . No entanto, ele não usa setTimeout, então a implementação de temporizadores falsos do sinon não aciona o retorno de chamada ao chamar 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 saída:

resolved
callback
test finished
teardown

Em vez disso, recebo isso:

resolved
test finished
teardown
callback

O retorno de chamada é invocado após a conclusão do teste, portanto, as asserções baseadas no que acontece dentro do retorno de chamada falham.

Não faz diferença se a promessa é resolvida antes ou depois de chamar then() .

Comentários muito úteis

A única coisa que você precisa é esperar que a microtarefa de promessa seja executada.
Portanto, a abordagem a seguir funciona perfeitamente:

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 comentários

Eu também passei por esse problema

Eu não acho que isso possa ser resolvido, pois a especificação Promise não usa setTimeout , mas agenda um novo trabalho para ser feito logo após o atual.

Você já tentou retornar a promessa do teste? A maioria das bibliotecas de teste suporta promessas agora, e se a função it() retornar uma promessa, o executor de teste aguardará que a promessa seja resolvida ou rejeitada antes de observar o teste como concluído.

Sim, retornar uma promessa de um teste provavelmente funciona. Mas ao usar temporizadores falsos, eu esperaria poder competir no teste antes de retornar da função de teste. Esse é o ponto todo.

Tenho certeza de que isso pode ser feito substituindo o objeto Promise, assim como o objeto Date é substituído. Para poder fazer isso, provavelmente precisamos de uma implementação competitiva da especificação de promessa, já que não poderemos contar com a implementação nativa.

Eu vejo o problema que vocês estão apontando aqui. No entanto, eu queria chamar a atenção para a situação específica que encontrei, pois, com base na especificação da promessa e na documentação do sinon, pareceu-me que isso "deveria" funcionar. Nesse caso, estou retornando a promessa ao teste, mas o uso de temporizadores falsos parece impedir que a promessa seja resolvida.

Pareceu-me que ambos os testes deveriam funcionar, mas o primeiro passa enquanto o segundo falha. Estou usando chai com o plugin chai-as-promised com mocha e recebo o erro "timeout de 2000ms excedido. Certifique-se de que o retorno de chamada done() está sendo chamado neste teste."

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

});

Montei um projeto de teste rápido: https://github.com/JustinLivi/sinon-promises-test

Restaurar antes da conclusão do teste parece ser uma solução viável:

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

});

A solução alternativa que estamos usando é substituir a implementação da promessa nativa por algo simples que depende de setTimeout, ao executar testes:

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

O Sinon.JS poderia simplesmente fazer o mesmo ao chamar useFakeTimers.

@JustinLivi restaurando antes da conclusão fez isso por mim :+1:

Então, alguma solução ou solução alternativa?
Não consigo modificar meu aplicativo e parar de usar ES6 Promises, porque meus testes dizem isso :)

Eu tenho o problema, não com promessas nativas, mas com o polyfill es6-promise.
A solução clock.restore() de @JustinLivi corrige o problema.

Não faço ideia de por que esse problema persiste aqui há tanto tempo, mas isso não é um problema com a Sinon. Usar Promises é essencialmente executar lógica assíncrona. Ainda assim, todos os testes aqui estão usando lógica síncrona (como o OP realmente menciona). Então, mesmo que você esteja fingindo tempo, você ainda está contando com a Promise para executar depois que sua função terminar, o que significa que você precisa alterar um pouco seu teste. Isso geralmente é abordado nos documentos da maioria dos frameworks de teste ( aqui está um do Mocha ), mas abordaremos isso com alguns artigos apresentando receitas de teste no próximo novo site.

Então, mudando um pouco o exemplo do @JustinLivi , acabamos com o seguinte teste:

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

});

Na verdade, se você estiver usando o Mocha, ele tem uma versão "atalho" do mesmo código acima ao usar Promises, se você apenas retornar a promessa diretamente ao framework de teste:

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

    this.clock.tick( 10001 );

    return promise;
});

Isso vai

  1. Execute a função executor enviada como parâmetro para o construtor Promise de forma síncrona (veja a especificação ES2015 ), configurando efetivamente o novo tempo limite.
  2. Retorne a promessa construída.
  3. Marque o tempo.
  4. Acionar a função de tempo limite, marcando a promessa como resolvida
  5. process (ou o navegador) empilhará um novo "tick", resolvendo a promessa e chamando qualquer Thenables restante

Fechando isso como um não-bug.

@fatso83 Não vejo como você está fazendo os testes passarem da maneira que descreveu. Adicionei seus testes de exemplo ao meu repositório de testes e ainda recebo timeout of 2000ms exceeded. Ensure the done() callback is being called in this test.

Veja aqui: https://github.com/JustinLivi/sinon-promises-test/blob/master/test.js#L60
Além disso, até onde eu sei, este teste ainda falha: https://github.com/JustinLivi/Sinon.JS/commit/de106e6db2f5cc076b7e3a78635bd9ae2b6be1c2

Edit: com base no comentário de @fpirsch , parece que é apenas ao usar o polyfill de promessa es6? Alguém já tentou com alguma biblioteca alternativa, como bluebird?

@fatso83 Este NÃO é um problema return promise .
No meu caso, este é um problema Phantom (antigo, sem promessas) + es6-promises polyfill + sinon.js. Em navegadores modernos a promessa é resolvida e os testes rodam bem, mas com a promessa polyfill a promessa nunca resolve quando os temporizadores são falsificados.

@JustinLivi e @fpirsch : este relatório de bug foi sobre o uso de promessas _native_. Eu vejo o projeto de teste do @JustinLivi usando es6-promise , então não posso garantir isso. O mesmo vale para outros polyfills: isso seria outro problema. Eu testei isso com o Node 5 e 6, que têm suporte a promessas nativos.

Copie e cole meu código de exemplo do post anterior e execute-o (com Mocha e Sinon pré-instalados):

$ pbpaste > test.js

$ mocha test.js

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

  1 passing (12ms)

@fpirsch : Eu nunca disse que era um problema de return promise . Acabei de mencionar que ele poderia reescrever o teste em uma forma mais curta. Uma dica, não uma correção. Mas você forneceu algumas informações valiosas: funciona em navegadores nativos, mas falha na promessa polyfill. Isso é uma falha da sua promessa lib, não da Sinon. Sua lib de promessa provavelmente não armazena em cache setTimeout e amigo, e depende dela para funcionar, quebrando assim. Eu implementei uma correção para exatamente isso na biblioteca pinkyswear uma vez, então não é de admirar.

Verificado a fonte de es6-promise e não armazena referências em cache para setTimeout , então ele será afetado por qualquer coisa que o sinon faça. Mas não consigo ver como esse agendador é relevante aqui ... Há também uma lista enorme de outras opções que o polyfill tentará antes de voltar ao agendador de tempo limite. Qualquer navegador que desembarque nesse fallback deve ser antigo :)

Mas de qualquer forma, o que @fpirsch e @JustinLivi mencionam são questões relacionadas à interoperabilidade entre Sinon e o polyfill. E isso é outra questão. Não vendo como a Sinon pode fazer muito sobre isso ATM (qualquer PR é mais provável que acabe em es6-polyfill ), mas se este for um problema da Sinon, sinta-se à vontade para abrir um novo problema.

PS Vejo que a dica do @ropez foi usar promise-polyfill , e essa é de fato nossa recomendação para um polyfill mínimo também, mas pelo motivo oposto ao dado. @mroderick corrigiu esse projeto em janeiro (alguns meses após o comentário de @ropez ) para armazenar em cache a referência a setTimeout para que a promessa resolvesse _não importa o que a sinon estivesse fazendo_ para a referência global setTimeout . Veja taylorhakes/promise-polyfill#15 para mais detalhes.

@fatso83 obrigado pela informação. Infelizmente salvar uma referência para setTimeout como @mroderick fez para promise-polyfill não funciona no meu caso. Nem substituir es6-promise por promise-polyfill :-(
EDIT: ele faz em uma pilha simples phantom + mocha, mas não em nossa pilha completa com karma. Tão cansado disso.

@fatso83 Eu coloquei um exemplo aqui: faketimers
Afinal, o problema pode ser com o Karma... Vou tentar me aprofundar nisso.

@fpirsch : Acho que você está no caminho certo. Tanto você quanto @JustinLivi estão confiando no karma como um executor de testes. Parecem muito semelhantes. PS Eu apaguei meu comentário por achar que era totalmente supérfluo, pois Justin já forneceu um caso de teste que mostrava o problema, mas seu projeto de exemplo reforça a hipótese de que isso seja uma coisa do Karma, então obrigado!

Isso pode estar relacionado, mas não tenho certeza. Por que o terceiro teste abaixo falha? A falha específica é o tempo limite de teste 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 O terceiro teste falha porque clock.tick processa os executores de forma síncrona e, portanto, o .then(...) não é executado devido à sua natureza assíncrona :

onFulfilled ou onRejected não deve ser chamado até que a pilha de contexto de execução contenha apenas o código da plataforma.

@fatso83 Não conheço a posição dos desenvolvedores do lolex em relação às promessas encadeadas que adicionam temporizadores. A limitação atual deve pelo menos ser detalhada na documentação.

Dependendo se isso deve ser suportado ou não, esse problema pode ser reaberto (ou um novo pode ser aberto) e depende de um novo problema no lolex .
Eu também acho que se este caso de uso (promessas de encadeamento que adicionam temporizadores) tiver que ser levado em consideração, isso levará a uma modificação da API lolex disruptiva (AFAIK clock.tick terá que ser assíncrono).

@gautaz Ah faz sentido agora. Obrigado por explicar!

lolex é uma biblioteca de sincronização e não tenho certeza de como resolver o problema de @jasonkuhrt de qualquer maneira. Sinta-se à vontade para fornecer um PR, mas modificar clock.tick não é muito atraente. Eu prefiro ver um método assíncrono adicional.

Eu nunca tive muitos problemas com promessas e tique-taque do relógio, mas isso foi provavelmente porque eu tinha visto que eles eram síncronos e adicionados tiques de relógio extras no meio.

@fatso83 Você está certo, adicionar um tick assíncrono seria menos perturbador (na verdade, não atrapalharia nada).
Então, algo como asyncTick pode ser o ajuste certo. Vou pesquisar se tiver tempo.

@gautaz apenas curioso se você já conseguiu fornecer um patch para lolex ?

@fatso83 Oi, eu comecei uma filial no ano passado do meu lado, mas nunca tive tempo para terminar o trabalho.
Tenho colegas que também foram enganados pelo mesmo problema, então só posso esperar que o problema fique grande o suficiente para me dar mais tempo.

Algo mudou nesse meio tempo em relação à API lolex nesse ponto específico?

Não deveria. Poucas mudanças na base de código no último semestre. Bastante estável.

Alguma chance de ter uma solução simples aqui? Tem o mesmo problema. Eu tenho duas promessas e elas são resolvidas por setTimeout. Eu preciso verificar as coisas antes de qualquer resolução, antes da segunda, mas depois da primeira resolução e depois de todas as resoluções.

A única coisa que você precisa é esperar que a microtarefa de promessa seja executada.
Portanto, a abordagem a seguir funciona perfeitamente:

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

O truque do @jakwuh funcionou para mim (Nó 8, sem transpilação, promessas nativas), exceto que os retornos de chamada then foram adiados por um tique.

Comentário atualizado : A solução no meu comentário original (abaixo) tem problemas. Às vezes eu precisava ter várias chamadas consecutivas de await Promise.resolve() para realmente liberar tudo. Aqui está algo que parece funcionar um pouco melhor:

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

Comentário Original [AVISO: Não funciona tão bem quanto o código acima.]

Adicionar uma lacuna extra assíncrona ao auxiliar tick resolveu o problema para mim:

``` js
const tick = assíncrono (ms) => {
clock.tick(ms);
aguardar Promessa.resolve();
};

Para as pessoas interessadas no trabalho para melhorar a Sinon nessa área (na verdade, seu projeto irmão lolex ), confira as discussões aqui:

Esta página foi útil?
0 / 5 - 0 avaliações