admin管理员组

文章数量:1336632

I am aware there are a lot of similar questions on SO, but I believe mine is different and not answered by any of the current answers.

I am testing a REST API in Express.JS. Below is a minimal working example and several different numbered test cases.

const express = require("express");
let request = require("supertest");
const { promisify } = require("util");

const app = express();
request = request(app);
const timeOut = promisify(setTimeout);

const timeOut2 = time =>
  new Promise(resolve => {
    setTimeout(resolve, time);
  });

app.locals.message = "Original string";

app.get("/one", async (req, res) => {
  await timeOut(1000);
  res.send(app.locals.message);
});

app.get("/two", (req, res) => {
  res.send(app.locals.message);
});

app.get("/three", async (req, res) => {
  await timeOut2(1000);
  res.send(app.locals.message);
});

test("1. test promisify", async () => {
  expect.assertions(1);
  const response = await request.get("/one");
  expect(response.text).toEqual("Original string");
});

test("2. test promisify with fake timers", () => {
  expect.assertions(1);
  jest.useFakeTimers();
  request.get("/one").then(res => {
    expect(res.text).toEqual("Original string");
  });
  jest.runAllTimers();
});

test("3. test promisify with fake timers and returning pending promise", () => {
  expect.assertions(1);
  jest.useFakeTimers();
  const response = request.get("/one").then(res => {
    expect(res.text).toEqual("Original string");
  });
  jest.runAllTimers();
  return response;
});

test("4. test no timeout", async () => {
  expect.assertions(1);
  const response = await request.get("/two");
  expect(response.text).toEqual("Original string");
});

test("5. test custom timeout", async () => {
  expect.assertions(1);
  const response = await request.get("/three");
  expect(response.text).toEqual("Original string");
});

test("6. test custom timeout with fake timers", () => {
  expect.assertions(1);
  jest.useFakeTimers();
  const response = request.get("/three").then(res => {
    expect(res.text).toEqual("Original string");
  });
  jest.runAllTimers();
  return response;
});

Running the tests invividually shows that only test 5 passes. My first question then is why does test 5 pass and not test 1, considering they are exactly the same test, other than a different implementation of the promise-based delay. Both implementations work perfectly outside of Jest tests (tested using Supertest without Jest).

While test 5 does pass, it uses real timers so is not ideal. Test 6 should be the fake timer equivalent as far as I can see (I also tried a version with done() called inside the then body), yet this fails as well.

My web app has a route with a handler that uses util.promisify(setTimeout) so the fact that Jest falls on its face trying to test it, even with real timers, makes the framework much less useful to me. This seems to be a bug considering that a custom implementation (test 5) actually does work.

Nonetheless, Jest still doesn't work on test 6 with mock timers so even if I reimplement the delays in my app (which I don't want to do), I would still have to suffer slow tests that can't be sped up.

Are either of these issues expected behaviour? If not what am I doing wrong?

I am aware there are a lot of similar questions on SO, but I believe mine is different and not answered by any of the current answers.

I am testing a REST API in Express.JS. Below is a minimal working example and several different numbered test cases.

const express = require("express");
let request = require("supertest");
const { promisify } = require("util");

const app = express();
request = request(app);
const timeOut = promisify(setTimeout);

const timeOut2 = time =>
  new Promise(resolve => {
    setTimeout(resolve, time);
  });

app.locals.message = "Original string";

app.get("/one", async (req, res) => {
  await timeOut(1000);
  res.send(app.locals.message);
});

app.get("/two", (req, res) => {
  res.send(app.locals.message);
});

app.get("/three", async (req, res) => {
  await timeOut2(1000);
  res.send(app.locals.message);
});

test("1. test promisify", async () => {
  expect.assertions(1);
  const response = await request.get("/one");
  expect(response.text).toEqual("Original string");
});

test("2. test promisify with fake timers", () => {
  expect.assertions(1);
  jest.useFakeTimers();
  request.get("/one").then(res => {
    expect(res.text).toEqual("Original string");
  });
  jest.runAllTimers();
});

test("3. test promisify with fake timers and returning pending promise", () => {
  expect.assertions(1);
  jest.useFakeTimers();
  const response = request.get("/one").then(res => {
    expect(res.text).toEqual("Original string");
  });
  jest.runAllTimers();
  return response;
});

test("4. test no timeout", async () => {
  expect.assertions(1);
  const response = await request.get("/two");
  expect(response.text).toEqual("Original string");
});

test("5. test custom timeout", async () => {
  expect.assertions(1);
  const response = await request.get("/three");
  expect(response.text).toEqual("Original string");
});

test("6. test custom timeout with fake timers", () => {
  expect.assertions(1);
  jest.useFakeTimers();
  const response = request.get("/three").then(res => {
    expect(res.text).toEqual("Original string");
  });
  jest.runAllTimers();
  return response;
});

Running the tests invividually shows that only test 5 passes. My first question then is why does test 5 pass and not test 1, considering they are exactly the same test, other than a different implementation of the promise-based delay. Both implementations work perfectly outside of Jest tests (tested using Supertest without Jest).

While test 5 does pass, it uses real timers so is not ideal. Test 6 should be the fake timer equivalent as far as I can see (I also tried a version with done() called inside the then body), yet this fails as well.

My web app has a route with a handler that uses util.promisify(setTimeout) so the fact that Jest falls on its face trying to test it, even with real timers, makes the framework much less useful to me. This seems to be a bug considering that a custom implementation (test 5) actually does work.

Nonetheless, Jest still doesn't work on test 6 with mock timers so even if I reimplement the delays in my app (which I don't want to do), I would still have to suffer slow tests that can't be sped up.

Are either of these issues expected behaviour? If not what am I doing wrong?

Share Improve this question edited Oct 9, 2018 at 19:25 Bergi 666k161 gold badges1k silver badges1.5k bronze badges asked Oct 9, 2018 at 18:27 raikseyraiksey 3413 silver badges10 bronze badges 7
  • 1 "why does test 5 pass and not test 1, considering they are exactly the same test" - test 5 uses an async function and does return a promise to jest, it is more similar to test 3 than to test 1. – Bergi Commented Oct 9, 2018 at 18:36
  • Yes, Jest not being able to mock a promisified setTimeout sounds like a bug. Or should we call it a feature of promisify? – Bergi Commented Oct 9, 2018 at 18:37
  • @skyboyer Are you sure? async functions return promises without a return keyword, do they not? Note that none of the examples in the Jest docs use return when they have an async function. – raiksey Commented Oct 9, 2018 at 19:08
  • @Bergi That's my mistake. I updated it in my editor but didn't copy the changes over here. 1 and 5 are now pletely equivalent – raiksey Commented Oct 9, 2018 at 19:10
  • 1 @skyboyer In fact if you look at the docs for setTimeout, you see that the promisified version is directly referred to and example given. – raiksey Commented Oct 10, 2018 at 1:28
 |  Show 2 more ments

1 Answer 1

Reset to default 7

This is an interesting question. It gets all the way down to the implementation of core built-in functions.


Why does test 5 pass and not test 1

This took a while to chase down.

The default test environment in Jest is jsdom and jsdom provides its own implementation for setTimeout.

Calling promisify(setTimeout) in the jsdom test environment returns the function created by running this code on the setTimeout provided by jsdom.

In contrast, if Jest is running in the node test environment calling promisify(setTimeout) simply returns the built-in node implementation.

This simple test passes in the node test environment, but hangs in jsdom:

const { promisify } = require('util');

test('promisify(setTimeout)', () => {
  return promisify(setTimeout)(0).then(() => {
      expect(true).toBe(true);
    });
});

Conclusion: The promisify-ed version of the setTimeout provided by jsdom doesn't work.

Test 1 and test 5 both pass if run in the node test environment


Test code that uses promisify(setTimeout) with Timer Mocks

It sounds like the real question is how to test code like this with Timer Mocks:

app.js

const express = require("express");
const { promisify } = require("util");

const app = express();
const timeOut = promisify(setTimeout);

app.locals.message = "Original string";

app.get("/one", async (req, res) => {
  await timeOut(10000);  // wait 10 seconds
  res.send(app.locals.message);
});

export default app;

This took a while to figure out, I'll walk through each part.

Mock promisify(setTimeout)

It is not possible to test code that uses promisify(setTimeout) using Timer Mocks without mocking promisify(setTimeout):

  • In the jsdom environment promisify(setTimeout) will hang.
  • In the node environment promisify(setTimeout) will be the implementation provided by node which doesn't call setTimeout so when jest.useFakeTimers replaces the global setTimeout it won't have any effect.

promisify(setTimeout) can be mocked by creating the following __mocks__/util.js:

const util = require.requireActual('util');  // get the real util

const realPromisify = util.promisify;  // capture the real promisify

util.promisify = (...args) => {
  if (args[0] === setTimeout) {  // return a mock if promisify(setTimeout)
    return time =>
      new Promise(resolve => {
        setTimeout(resolve, time);
      });
  }
  return realPromisify(...args);  // ...otherwise call the real promisify
}

module.exports = util;

Note that calling jest.mock('util'); in the test is required since util is a core Node module.

Call jest.runAllTimers() on an interval

As it turns out, request.get kicks off a whole process in supertest that uses the JavaScript Event Loop and doesn't run anything until the current running message (the test) has pleted.

This is problematic since request.get will eventually run app.get which will then call await timeOut(10000); which won't plete until jest.runAllTimers has been called.

Anything in the synchronous test will run before request.get does anything so if jest.runAllTimers is run during the test it won't have any effect on the later call to await timeOut(10000);.

The workaround for this issue is to set up an interval that periodically queues messages in the JavaScript Event Loop that call jest.runAllTimers. When the message that calls await timeOut(10000); runs it will pause on that line, a message calling jest.runAllTimers will then run, and the message waiting for await timeOut(10000); will then be able to proceed and request.get will plete.

Capture setInterval and clearInterval

The last point to note is that jest.useFakeTimers replaces the global timer functions including setInterval and clearInterval so in order to set up our interval and clear it we need to capture the real functions before calling jest.useFakeTimers.


With all that in mind, here is a working test for the app.js code listed above:

jest.mock('util');  // core Node.js modules must be explicitly mocked

const supertest = require('supertest');
import app from './app';

const request = supertest(app);

const realSetInterval = setInterval;  // capture the real setInterval
const realClearInterval = clearInterval;  // capture the real clearInterval

beforeEach(() => {
  jest.useFakeTimers();  // use fake timers
});

afterEach(() => {
  jest.useRealTimers();  // restore real timers
});

test("test promisify(setTimeout) with fake timers", async () => {
  expect.assertions(1);

  const interval = realSetInterval(() => {
    jest.runAllTimers();  // run all timers every 10ms
  }, 10);

  await request.get("/one").then(res => {
    realClearInterval(interval);  // cancel the interval
    expect(res.text).toEqual("Original string");  // SUCCESS
  });
});

本文标签: javascriptJest doesn39t work with utilpromisify(setTimeout)Stack Overflow