jest.useFakeTimers 后,setTimeout 还是宏任务么?

1,782 阅读3分钟

开篇先提一个问题:jest.useFakeTimers 后,setTimeout 还是宏任务么?

业务场景

业务上有一个需求,需要对若干主机进行连通性测试来选择一个响应时长最短的作为最优节点,代码如下:

import axios from "axios";

/**
 * 获取一组主机地址中响应时长最短的节点
 *
 * @param {Array<string>} hosts 主机域名加端口号数组
 * @returns {Promise<string>} 响应时长最短的节点地址
 */
export function getFatestHost(hosts) {
  return new Promise((resolve) => {
    hosts.forEach((host) => axios.get(`http://${host}/api/v1/ping`).then(() => resolve(host)));
  });
}

单元测试

可以看到,代码并不复杂。接下来就是,对这样一项功能如何去做单元测试呢?我们可以劫持 axios.get 方法,然后通过 setTimeout 方法来模拟不同时长的响应,并基于预设的响应时长对 getFastestHost 的返回结果做断言。我们设定第一次 axios 请求耗时 20ms,第二次请求耗时 10ms,期望返回结果是耗时 10ms 的主机地址,整体代码如下:

import { getFatestHost } from "./index.js";
import axios from "axios";

test("test getFatestHost", () => {
  jest
    .spyOn(axios, "get")
    .mockReturnValueOnce(
      new Promise((resolve) => {
        setTimeout(() => {
          resolve({ status: 200 });
        }, 20);
      })
    )
    .mockReturnValueOnce(
      new Promise((resolve) => {
        setTimeout(() => {
          resolve({ status: 200 });
        }, 10);
      })
    );
  const hostsArr = ["192.168.0.1:80", "192.168.0.2:80"];
  return expect(getFatestHost(hostsArr)).resolves.toBe(hostsArr[1]);
});

这里代码的执行逻辑如下:

  1. 执行所有的同步代码,依次发送 axios ping 请求,该请求实际被 jest 的 mock 函数劫持,并产生两个 setTimeout 宏任务
  2. 10 ms 后第一个宏任务触发,并在宏任务执行过程中产生两个 promise.then 微任务
  3. 宏任务执行完成,resolve axios.get 微任务,然后 resolve getFatestHost 的 Promise 返回
  4. 返回 host 结果,测试结束。

这一过程由于两个 setTimeout 都是宏任务,所以即使中间没有 10ms 的时间间隔,也会在执行完 10ms setTimeout 所建立的一系列微任务之后才会执行 20ms 的宏任务,这就使得测试用例结果和预期是一致的。

单元测试用例是跑通了,但是却不是最理想的解决方案,因为 setTimeout 的引入使得我们的测试变得低效。不过 jest 为我们提供了控制时间流转的办法,比如 jest.runAllTimers() 就可以让我们将时间快进以迅速完成所有的计时器,更准确来说,是完成所有的宏任务和微任务。

Exhausts both the macro-task queue (i.e., all tasks queued by setTimeout(), setInterval(), and setImmediate()) and the micro-task queue (usually interfaced in node via process.nextTick).

基于此我们来对单元测试用例进行一次改造:

import { getFatestHost } from "./index.js";
import axios from "axios";

test("test getFatestHost", () => {
+ jest.useFakeTimers();
  jest
    .spyOn(axios, "get")
    .mockReturnValueOnce(
      new Promise((resolve) => {
        setTimeout(() => {
          resolve({ status: 200 });
        }, 20);
      })
    )
    .mockReturnValueOnce(
      new Promise((resolve) => {
        setTimeout(() => {
          resolve({ status: 200 });
        }, 10);
      })
    );
+ jest.runAllTimers();
  const hostsArr = ["192.168.0.1:80", "192.168.0.2:80"];
  return expect(getFatestHost(hostsArr)).resolves.toBe(hostsArr[1]);
});

结果这一次测试用例却失败了,看起来代码逻辑似乎没什么问题,仅仅是用 jest 对 setTimeout 进行了加速,就导致了不同的返回结果。

Expected: "192.168.0.2:80"
Received: "192.168.0.1:80"

我们添加上打印信息来探究一下代码执行的顺序:

// index.spec.js
test("test getFatestHost", () => {
  jest.useFakeTimers();

  jest
    .spyOn(axios, "get")
    .mockReturnValueOnce(
      new Promise((resolve) => {
        console.log("Promise1.created");
        setTimeout(() => {
          console.log("setTimeout 20ms ");  // 2
          resolve({ status: 200, data: "setTimeout 20ms" });
        }, 20);
      })
    )
    .mockReturnValueOnce(
      new Promise((resolve) => {
        console.log("Promise2.created");
        setTimeout(() => {
          console.log("setTimeout 10ms ");  // 1
          resolve({ status: 200, data: "setTimeout 10ms" });
        }, 10);
      })
    );
  
  console.log("before jest.RunAllTimers");
  jest.runAllTimers();
  console.log("after jest.RunAllTimers");
  
  const hostsArr = ["192.168.0.1:80", "192.168.0.2:80"];
  return expect(getFatestHost(hostsArr)).resolves.toBe(hostsArr[1]);
});

// index.js
export function getFatestHost(hosts) {
  return new Promise((resolve) => {
    hosts.forEach((host) =>
      axios.get(`http://${host}/api/v1/ping`).then(({ data }) => {
        console.log(`${host} ping resolved: `, data);  // 3、4
        resolve(host);
      })
    );
  });
}

// 打印顺序:
// Promise1.created
// Promise2.created
// before jest.RunAllTimers
// setTimeout 10ms
// setTimeout 20ms
// after jest.RunAllTimers
// 192.168.0.1:80 ping resolved:setTimeout 20ms
// 192.168.0.2:80 ping resolved:setTimeout 10ms

这里就发现了很多细节:

  1. Promise.created 先于 jest.RunAllTimers,说明 jest.mock 在创建的时候就立即执行了回调函数,而不是在回调函数触发的时候才执行。
  2. setTimeout 函数在 jestRunAllTimers 的时候全部打印出来了,说明 jest.useFakerTimerssetTimeout 从异步的宏任务执行变成了同步执行,而 setTimeout 的延迟时间只是决定了同步代码执行的顺序。
  3. 调用 getFatestHost 的时候,setTimeout 以及执行完了,axios.get 会立即返回一个 resolved 的 promise,所以这个时候 promise.then 的执行顺序就只取决于 Promise 的创建时间,而不是 resolve 的时间了。

这样就完整的解释了测试用例失败的奇怪原因。

说到这里我们先来简单复习一下事件循环:

js 执行完同步代码之后,会先查看微任务队列,并依次执行到队列为空,然后再取出一个宏任务进行执行,执行过程中如果产生了新的微任务,那么会在宏任务执行完之后继续执行微任务直到微任务队列为空,如此循环往复。

对于本文所涉及到的,需要了解:setTimeout 是宏任务,而 Promise.then 是微任务。

解决方案

接下来回到正题,我们应该如何改造测试用例使得能够正确的测试业务代码呢?这里有两个思路:

第一个思路:还原真实场景,快进到第一个 setTimeout,模拟一个请求响应而另外一个请求尚未响应的情景,这里 jest 为我们提供了 advanceTimersToNextTimer 方法用于实现只快进到下一个 timer,代码改造如下:

import { getFatestHost } from "./index.js";
import axios from "axios";

test("test getFatestHost", () => {
  jest.useFakeTimers();  
	jest
    .spyOn(axios, "get")
    .mockReturnValueOnce(
      new Promise((resolve) => {
        setTimeout(() => {
          resolve({ status: 200 });
        }, 20);
      })
    )
    .mockReturnValueOnce(
      new Promise((resolve) => 
        setTimeout(() => {
          resolve({ status: 200 });
        }, 10);
      })
    );

  jest.advanceTimersToNextTimer();
  const hostsArr = ["192.168.0.1:80", "192.168.0.2:80"];
  return expect(getFatestHost(hostsArr)).resolves.toBe(hostsArr[1]);
}

第二个思路:同时完成两个请求的模拟响应,但是这里要注意避免同步代码先于业务代码执行:

import { getFatestHost } from "./index.js";
import axios from "axios";

test("test getFatestHost", () => {
  jest.useFakeTimers();  
	jest
    .spyOn(axios, "get")
    .mockReturnValueOnce(
      new Promise((resolve) => {
        setTimeout(() => {
          resolve({ status: 200 });
        }, 20);
      })
    )
    .mockReturnValueOnce(
      new Promise((resolve) => 
        setTimeout(() => {
          resolve({ status: 200 });
        }, 10);
      })
    );

  const hostsArr = ["192.168.0.1:80", "192.168.0.2:80"];
  return new Promise((resolve) => {
    getFatestHost(hostsArr).then((res) => {
      expect(res).toBe(hostsArr[1]);
      resolve();
    });
    jest.runAllTimers();
  });
}

这里的情况之所以复杂,主要在于:

  1. 在同一个测试用例里同时混杂了微任务、宏任务的执行逻辑,需要对 event loop 有深刻的认知才能捋清楚代码的执行顺序。
  2. jest 中将 setTimeout 从宏任务变成了同步代码,仅仅是发现这一点就花费了挺大精力
  3. jest mock 回调函数的执行时间是创建时而不是触发时,这一点也与正常的逻辑思维有所偏差