fast-check 4.2.0 新特性:用 waitIdle 与 waitNext 让竞态条件检测更可预测

原创2026-10-13 09:31:471,609 阅读
文章标签:开发工具测试

fast-check 4.2.0 新特性:用 waitIdle 与 waitNext 让竞态条件检测更可预测

fast-check 4.2.0 围绕 fc.scheduler 引入了两项关键新原语:waitIdle(更完善的 waitAll)与 waitNext(更完善的 waitOne),用于在竞态条件检测中更稳定地等待调度器真正完成。本文结合仓库源码与测试用例,深入讲解这两个 API 的用法、底层实现原理、与旧 API 的差异,以及从 4.1.0 到 4.2.0 的完整变更清单。

背景:为什么 waitOne / waitAll 变得难以驾驭

自 fast-check 4.0.0 起,官方决定让 waitOne 和 waitAll 的行为变得可预测:移除了它们原先实现中一个微妙的"陷阱(gotcha)"——旧实现的结果会依赖"调度下一个任务之前需要等待多少个微任务(micro-task)"。这一改动消除了阈值效应,但副作用是这两个 API 变得更难使用,竞态条件检测的测试代码也因此更难编写。

相比之下,waitFor 一直保持易用——只是它要求使用者自己构造一个"可以等待的东西"(一个目标 Promise)传进去。4.2.0 正是要在"易用"与"可预测"之间重新取得平衡,于是引入了 waitIdle 与 waitNext 两个新原语。这两个原语在 Scheduler 接口中的完整定义可见于 Scheduler 接口源码,其中 waitIdle 与 waitNext 均标注为 @remarks Since 4.2.0。

waitIdle:一个更好的 waitAll

API 签名

waitIdle: (customAct?: SchedulerAct) => Promise<void>;

waitIdle 提供了一种简单、高效且可预测的方式来等待 fc.scheduler 调度出的所有任务执行完毕。它没有旧版本 waitAll 的那些"陷阱",同时 API 用法几乎一样。官方对该方法的语义描述(见 Scheduler 接口源码)要点如下:

  • 等待调度器进入空闲状态:所有已调度且可达(reachable)的任务都已完成;
  • 它会递归地包含其他任务新调度出来的任务;
  • 注意:由不可控来源(如 fetch 或外部事件)触发的任务无法被检测或等待,可能导致等待不完整;
  • 若想等待某个精确事件,应优先使用 waitFor 或 waitNext,因为它们对"究竟在等待什么"提供更细粒度的控制。

用官方示例对比 waitAll 与 waitIdle

原文档给出了同一份测试在两种写法下的对比。先看自 4.2.0 起推荐的写法(waitIdle):

import fc from 'fast-check';
import { test, expect } from 'vitest';

test('our test', async () => {
  await fc.assert(
    fc.property(fc.scheduler(), async (s) => {
      const fetchIdFor = s.scheduleFunction(async (name) => `id:${name}`);
      const { doStuff, warmup } = buildDoStuff(fetchIdFor);
      await warmup();
      let done = false;
      doStuff('name').then(() => (done = true));
      await s.waitIdle();
      expect(done).toBe(true);
    }),
  );
});

// 在真实项目中,下面的函数通常会定义在测试文件之外
// 的独立模块里,这里为便于演示放在同一文件。
function buildDoStuff(fetchIdFor) {
  return {
    doStuff: async function doStuff(name) {
      const { default: executeTaskOnId } = await import('./executor');
      await executeTaskOnId(await fetchIdFor(name));
    },
    warmup: async function warmup(name) {
      await import('./executor');
    },
  };
}

再看 4.x 早期版本的写法(waitAll,注意当时异步属性还是 fc.asyncProperty):

import fc from 'fast-check';
import { test, expect } from 'vitest';

test('our test', async () => {
  await fc.assert(
    fc.asyncProperty(fc.scheduler(), async (s) => {
      const fetchIdFor = s.scheduleFunction(async (name) => `id:${name}`);
      const { doStuff, warmup } = buildDoStuff(fetchIdFor);
      await warmup();
      let done = false;
      doStuff('name').then(() => (done = true));
      await s.waitAll();
      expect(done).toBe(true);
    }),
  );
});

function buildDoStuff(fetchIdFor) {
  return {
    doStuff: async function doStuff(name) {
      const { default: executeTaskOnId } = await import('./executor');
      await executeTaskOnId(await fetchIdFor(name));
    },
    warmup: async function warmup(name) {
      await import('./executor');
    },
  };
}

原文档明确指出:上面这个测试在 waitAll 版本下无法通过。原因在于:doStuff 中的 fetchIdFor 调用被延迟得太久,waitAll 根本看不到它——实际上没有任何任务在期限内被调度,于是 waitAll 立即返回。此时若在 waitAll 执行后调用 s.report() 检查输出,可以确认:调度器尚未释放任何任务、也尚未"见过"任何任务。整体来看,waitAll 让测试变得更难推理:它可能因为依赖微任务数量的、不明显的原因而失败。

换成 waitIdle 后测试就能通过:调用会被真正触发,调度器会调度出包括 fetchIdFor 那次调用在内的全部预期任务。

底层实现:waitIdle 是如何做到的

两个新原语都建立在内部方法 internalWaitFor 之上(见 SchedulerImplem 实现)。internalWaitFor 内部维护一个"等待者(awaiter)"循环:每当调度器有新任务被注册(通过 scheduledWatchers 通知机制),就会启动一轮等待者;等待者会先消耗固定数量的微任务(源码中 numTicksBeforeScheduling = 50,见 SchedulerImplem.ts#L12),给后续异步调度留出时间,然后再释放一个已调度的任务,如此反复。

waitIdle 的实现(SchedulerImplem.ts#L387-L396)与 waitFor 的关键差异在于两个参数:

waitIdle(customAct?: SchedulerAct): Promise<void> {
  let resolver: (() => void) | undefined = undefined;
  const awaited = new Promise<void>((r) => (resolver = r));
  return this.internalWaitFor(awaited, {
    customAct,
    onWaitStart: undefined,
    onWaitIdle: resolver,        // ← 空闲时触发
    launchAwaiterOnInit: true,   // ← 一开始就启动等待者
  });
}
  • onWaitIdle: resolver:当等待者确认"目标 Promise 尚未解决、且当前没有已调度任务可释放"时,会回调 onWaitIdle(见 internalWaitFor 中 if (!taskResolved && onWaitIdle !== undefined) onWaitIdle();),从而结束 waitIdle 的等待;
  • launchAwaiterOnInit: true:即使调用时刻没有任何已调度任务,也立即启动等待者循环,这正是它比 waitAll 更能"等到后到任务"的关键——waitAll 的实现只是简单的 while (this.scheduledTasks.length > 0) await this.waitOne(...)(SchedulerImplem.ts#L239-L243),一旦调度队列为空就立刻结束,不会给"迟到的调度"留机会。

测试用例佐证 waitIdle 的行为边界

仓库在 SchedulerImplem.spec.ts 中为 waitIdle 设计了五类测试,恰好勾勒出它的能力边界:

  1. 没有任务可调度时:await s.waitIdle() 应正常返回,s.count() 为 0;
  2. 立即调度的任务:5 个 s.schedule(Promise.resolve(i)) 全部执行完毕;
  3. 经由多层微任务才调度的任务:任务经过 4 个 .then(v => v) 微任务后才被 s.schedule 注册,waitIdle 依然能全部等到——这正是它优于 waitAll 的典型场景;
  4. 递归调度的任务:每个任务完成后又调度下一个任务(previous = previous.then(...)),waitIdle 会递归等待到底;
  5. 不可控的 Promise:一个不被调度器控制的 uncontrollable Promise 在 resolve 后才触发 s.schedule(...),waitIdle 不会傻等它——测试断言 waitIdle 返回时 count() 为 0,随后手动 resolve 该 Promise 并再等一个 tick 后 count() 才变为 1。这印证了接口文档中"由不可控来源触发的任务可能无法被等待"的提示。

waitNext:一个更好的 waitOne

与 waitIdle 思路一致,官方同时创造了 waitNext。它不是等待"全部完成",而是精确等待恰好 N 个任务被调度执行——无论具体是哪些任务。可以把它理解为 waitOne 的增强版:既增加了"等待任务真正被调度"的能力,又支持按数量等待。

API 签名

waitNext: (count: number, customAct?: SchedulerAct) => Promise<void>;

底层实现

waitNext 的实现(SchedulerImplem.ts#L366-L385)复用同一套 internalWaitFor 机制,但回调方向相反:

waitNext(count: number, customAct?: SchedulerAct): Promise<void> {
  let resolver: (() => void) | undefined = undefined;
  let remaining = count;
  const awaited =
    remaining <= 0
      ? Promise.resolve()
      : new Promise<void>((r) => {
          resolver = () => {
            if (--remaining <= 0) {
              r();
            }
          };
        });
  return this.internalWaitFor(awaited, {
    customAct,
    onWaitStart: resolver,       // ← 每释放一个任务就计数一次
    onWaitIdle: undefined,
    launchAwaiterOnInit: false,
  });
}
  • onWaitStart: resolver:等待者每释放一个已调度任务(internalWaitFor 中调用 waitOne 之前回调 onWaitStart),就将 remaining 减一,直到归零时结束等待;
  • 若 count <= 0,直接返回已解决的 Promise(立即结束);
  • launchAwaiterOnInit: false:与 waitIdle 不同,它不需要"预先占位",因为它的目标是明确数量的任务。

测试用例佐证

SchedulerImplem.spec.ts 中的 waitNext 测试 覆盖了两个关键行为:

  1. 精确释放 N 个:预先调度 10 个任务后调用 await s.waitNext(n),断言 s.count() 等于 10 - n——仍然有 10 - n 个任务处于 pending;
  2. 等待迟到的任务:任务经由 queue.then(() => delay(0)).then(() => s.schedule(...)) 链条逐个注册(并非调用时全部就绪),waitNext(n) 仍能等到第 n 个任务被调度并执行,断言 seenValues 等于 [0..n-1]——说明它与 waitIdle 一样,会为"后到任务"留出微任务窗口。

迁移指南:waitOne / waitAll 已弃用

4.2.0 起,waitOne 与 waitAll 被标记为废弃(@deprecated)。接口源码中的弃用说明(见 Scheduler 接口源码)分别是:

  • waitOne:@deprecated Use waitNext(1) instead, it comes with a more predictable behavior;
  • waitAll:@deprecated Use waitIdle() instead, it comes with a more predictable behavior awaiting all scheduled and reachable tasks to be completed。

迁移文档 from-3.x-to-4.x.md 中给出了明确的选型建议:

  • 任务数量已知 → waitNext(n);
  • 等待所有任务落定 → waitIdle();
  • 等待某个特定任务(哪怕它尚未被调度)→ waitFor(promise)。

waitFor 与两个新原语一样,都能应对"调用时任务尚未调度"的场景。进阶竞态检测指南 race-conditions.md 对此有完整说明:例如 supertest 这类测试框架只有在用户真正调用 then 时才会触发后续请求,waitFor 就是为了等待这类"尚未被调度"的调用而设计。

使用技巧:与 act 结合

fc.scheduler 的约束项 act 用于保证所有被调度的任务都在正确的上下文中执行(例如 React 测试中的 act),定义见 scheduler.ts。新的 waitIdle 与 waitNext 都保留了可选的 customAct 参数,可以按需覆盖等待层级的行为。

一个典型模式(摘自 race-conditions.md):给 fc.scheduler({ act }) 传入全局 act,然后在属性内部用 await s.waitIdle() 等待全部任务;当被测代码依赖 setTimeout / setInterval 等原生定时器时,还可以构造一个专门的 buildWrapWithTimersAct(s) 包装器,把"推进定时器"也变成可调度的任务,并通过 await s.waitIdle(buildWrapWithTimersAct(s)) 一并等待(完整实现见 race-conditions.md 的定时器示例)。

4.2.0 的其他变化

除了两个新原语,4.2.0 还包含以下值得关注的变化(对应 PR 编号见原文档 Changelog):

  • act 中弹出的错误不再被静默吞掉:此前包裹任务的 act 若抛错可能被悄悄忽略,此版本起错误会被正确传播(PR#5953);
  • 修复调度器执行期间任务重叠:避免同一时刻释放多个任务(PR#5900);
  • 性能优化:scheduler 通过显式 undefined 检查略微提速(PR#5901);
  • 测试与 CI 调整:停止在 Node 18 上测试(PR#6019)、为 Windows runner 分片 Vitest 执行、在 Mac OS 上测量覆盖率等。

从 4.1.0 到 4.2.0 的完整变更清单

4.2.0 基于 4.1.1 发布,以下是相对 4.1.0 的完整变更(PR 编号与原文档一致):

Features

  • (PR#5953) 不再静默 act 中弹出的错误
  • (PR#5890) 为 scheduler 引入新的等待器(awaiter)
  • (PR#6016) 引入 waitIdle,作为 scheduler 上重构版的 waitAll
  • (PR#6026) 弃用 waitOne 与 waitAll

Fixes

  • (PR#5900) Bug:避免 scheduler 执行期间任务重叠
  • (PR#5903) CI:仅在 Ubuntu + Node 22 上运行覆盖率
  • (PR#5904) CI:在 Windows runner 上分片 Vitest 执行
  • (PR#5907) CI:始终发布到 pkg-pr-new
  • (PR#5935) CI:移除 LFS 存储
  • (PR#5936) CI:通过哈希校验实现更安全、更快的静态资源
  • (PR#5943) CI:阻止 stale 机器人关闭已确认的想法
  • (PR#5954) CI:使 poisoning 测试兼容 Node 24
  • (PR#5969) CI:在 Mac OS 上测量覆盖率
  • (PR#5971) CI:为 Vitest 提供更好的排除列表
  • (PR#5989) CI:尝试稳定测试
  • (PR#5894) Doc:4.1.0 发布说明
  • (PR#5937) Doc:在博客作者页引用社交媒体链接
  • (PR#5938) Doc:修复 /docs 与 /blog 的社交图片
  • (PR#5965) Doc:更新 stringMatching 文档
  • (PR#5966) Doc:修复 model-based-testing.md 中的笔误
  • (PR#6015) Doc:新增贡献者 matthyk
  • (PR#5973) Test:移除测试中未使用的检查
  • (PR#6019) Test:停止针对 Node 18 测试
  • (PR#5901) Performance:通过显式 undefined 检查略微提升 scheduler 速度

总结

4.2.0 的核心价值在于:把"等待调度器"这件事从玄学变成工程。waitIdle 与 waitNext 基于同一套 internalWaitFor 微任务感知机制,让测试不再依赖"恰好多少个微任务之后才轮到下一次调度",从而大幅降低竞态条件检测测试的脆弱性。对于新代码,直接使用 waitIdle() / waitNext(n) / waitFor(promise) 即可;对于存量代码,按迁移文档将 waitAll 替换为 waitIdle、将 waitOne 替换为 waitNext(1),即可获得更稳定、更易推理的竞态检测体验。如需完整理解这三个原语与 schedule / scheduleFunction / scheduleSequence 的配合方式,可继续阅读 竞态条件检测进阶指南 与 3.x 到 4.x 迁移文档。

登录后查看全文
fast-check