جافاسكريبت - Jest لا يعمل مع util.promisify(setTimeout)


أنا على علم بوجود الكثير من الأسئلة المماثلة على SO، لكنني أعتقد أن سؤالي مختلف ولم يتم الرد عليه بأي من الإجابات الحالية.

أنا أقوم باختبار REST API في Express.JS. أدناه مثال عملي مبسط والعديد من حالات الاختبار المرقمة المختلفة.

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

تشغيل الاختبارات بشكل فردي يظهر أن الاختبار 5 فقط ينجح. سؤالي الأول إذن هو لماذا ينجح الاختبار 5 ولا ينجح الاختبار 1، مع العلم أنهما نفس الاختبار تمامًا، بخلاف تنفيذ مختلف لتأخير قائم على الوعود. كلا التنفيذين يعملان بشكل مثالي خارج اختبارات Jest (تم اختباره باستخدام Supertest بدون Jest).

بينما ينجح الاختبار 5، إلا أنه يستخدم مؤقتات حقيقية لذا فهو غير مثالي. يجب أن يكون الاختبار 6 مكافئ المؤقت المزيف على حد علمي (لقد جربت أيضًا نسخة مع استدعاء done() داخل جسم then)، ومع ذلك يفشل هذا أيضًا.

تطبيق الويب الخاص بي يحتوي على مسار مع معالج يستخدم util.promisify(setTimeout)، لذا فإن حقيقة أن Jest يفشل عند محاولة اختباره، حتى مع المؤقتات الحقيقية، تجعل الإطار أقل فائدة بالنسبة لي. يبدو أن هذا خطأ بالنظر إلى أن تنفيذًا مخصصًا (الاختبار 5) يعمل بالفعل.

مع ذلك، لا يزال Jest لا يعمل في الاختبار 6 باستخدام مؤقتات المحاكاة، لذلك حتى لو قمت بإعادة تنفيذ التأخيرات في تطبيقي (والذي لا أريد فعله)، فسأظل مضطراً لتحمل اختبارات بطيئة لا يمكن تسريعها.

هل أي من هذه المشكلات سلوك متوقع؟ إذا لم يكن كذلك، ماذا أفعل خطأ؟

الإجابة رقم 1

هذا سؤال مثير للاهتمام. إنه يصل إلى حد تنفيذ الوظائف الأساسية المدمجة.


لماذا ينجح الاختبار 5 ولا ينجح الاختبار 1

استغرق الأمر بعض الوقت لتتبعه.

بيئة الاختبار الافتراضية في Jest هي jsdom، وjsdom توفر تنفيذها الخاص لـ setTimeout.

استدعاء promisify(setTimeout) في بيئة اختبار jsdom يُرجع الدالة التي تم إنشاؤها بتشغيل هذا الكود على setTimeout المقدم من jsdom.

على العكس، إذا كان Jest يعمل في بيئة اختبار node، فإن استدعاء promisify(setTimeout) ببساطة يعيد تنفيذ node المدمج.

يجتاز هذا الاختبار البسيط في بيئة الاختبار node، لكنه يتوقف في jsdom:

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

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

الخلاصة: نسخة promisify من setTimeout المقدمة من jsdom لا تعمل.

يجتاز الاختبار 1 والاختبار 5 كلاهما إذا تم تشغيلهما في بيئة اختبار node


كود اختبار يستخدم promisify(setTimeout) مع محاكيات الموقت

يبدو أن السؤال الحقيقي هو كيفية اختبار كود مثل هذا باستخدام محاكيات المؤقت:

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;

استغرق هذا بعض الوقت للفهم، سأشرح كل جزء.

محاكاة promisify(setTimeout)

لا يمكن اختبار الكود الذي يستخدم promisify(setTimeout) باستخدام Timer Mocks دون محاكاة promisify(setTimeout):

  • في بيئة jsdom فإن promisify(setTimeout) سيتوقف عن العمل.
  • في بيئة node، ستكون promisify(setTimeout) هي التنفيذ المقدم من node والذي لا يستدعي setTimeout، لذلك عندما يقوم jest.useFakeTimers باستبدال setTimeout العالمي، فلن يكون له أي تأثير.

يمكن تمثيل promisify(setTimeout) عن طريق إنشاء __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;

لاحظ أن استدعاء jest.mock('util'); في الاختبار مطلوب لأن util هو وحدة أساسية في Node.

استدعِ jest.runAllTimers() على فاصل زمني

كما يتضح، يبدأ request.get عملية كاملة في supertest تستخدم حلقة أحداث جافا سكريبت ولا تقوم بتشغيل أي شيء حتى تكتمل الرسالة الجارية (الاختبار).

هذا يمثل مشكلة لأن request.get سيقوم في النهاية بتشغيل app.get الذي سيستدعي بعد ذلك await timeOut(10000); الذي لن يكتمل حتى يتم استدعاء jest.runAllTimers.

أي شيء في الاختبار التزامني سيعمل قبل أن يقوم request.get بأي شيء، لذا إذا تم تشغيل jest.runAllTimers خلال الاختبار فلن يكون له أي تأثير على الاستدعاء لاحقًا لـ await timeOut(10000);.

الحل البديل لهذه المشكلة هو إعداد فاصل زمني يقوم بشكل دوري بإدراج رسائل في حلقة أحداث JavaScript التي تستدعي jest.runAllTimers. عندما يتم تشغيل الرسالة التي تستدعي await timeOut(10000); ستتوقف عند هذا السطر، ثم ستُشغل رسالة تستدعي jest.runAllTimers، وبعدها ستتمكن الرسالة التي تنتظر await timeOut(10000); من المتابعة وسينتهي تنفيذ request.get.

التقاط setInterval و clearInterval

النقطة الأخيرة التي يجب ملاحظتها هي أن jest.useFakeTimers يستبدل وظائف المؤقت العالمية بما في ذلك setInterval و clearInterval، لذلك من أجل إعداد الفاصل الزمني الخاص بنا ومسحه، نحتاج إلى التقاط الوظائف الحقيقية قبل استدعاء jest.useFakeTimers.


مع وضع كل ذلك في الاعتبار، إليك اختبارًا عمليًا للكود app.js المذكور أعلاه:

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