使用 fast-check 检测 JavaScript 竞态条件:scheduler 实战指南

原创2026-10-13 13:55:061,398 阅读
文章标签:开发工具测试

使用 fast-check 检测 JavaScript 竞态条件:scheduler 实战指南

导读

竞态条件(race condition)是 JavaScript 异步编程中最隐蔽、最难复现的一类缺陷:它依赖外部事件的先后顺序,而事件的时序又不在我们的控制之中。本文以 fast-check 官方教程 detect-race-conditions 系列 为主线,从竞态条件的定义出发,通过一个 queue 函数(按序 resolve、禁止并发调用)的完整测试演进,逐步讲解 fc.scheduler() 及其 schedule、scheduleFunction、scheduleSequence、waitFor/waitIdle 等核心 API 的使用方法,并结合仓库源码与示例(examples/005-race)给出可复制的实战代码。读完本文,你将掌握一套可迁移到任何异步模块与 UI 组件的竞态条件自动化检测方案。

什么是竞态条件

定义与 JavaScript 中的两大诱因

按照维基百科的定义:"竞态条件是指系统实质行为依赖于其他不可控事件的序列或时序的状态。"(原文参见 race-conditions 高级文档 的引言)该定义点明了竞态条件在 JavaScript 中盛行的两个根本原因:

  • 事件的序列(sequence of events):谁先谁后直接决定结果;
  • 事件不可控(events being out-of-control):我们无法预测也无力控制事件发生的时刻。

考虑一个典型的场景:用户在文本框里输入文字。用户会聚焦输入框并产生一系列 keypress 事件;同时,如果文本框连接了后端 API,它会随时间收到来自服务端的更新。这两类事件都不受我们控制——我们既无法预测用户何时输入、输入什么,也无法预测 API 何时返回,竞态条件由此而生。

值得注意的是,JavaScript 虽然是单线程语言,但基于事件循环的模型并不会天然免疫竞态条件。任何允许"调度任务"的地方——Promise、setTimeout、事件回调、网络请求——都可能成为竞态条件的温床。

一个真实的例子:自动补全输入框

教程以一个自动补全(autocomplete)输入框为例。当用户在输入的同时,过期的建议以闪烁的方式出现又消失,用户很难在输入稳定之前选中任何选项——建议似乎"乱序"出现。更糟的是,如果等待足够久,输入框可能最终稳定在一个过去的查询结果上:页面展示的建议与当前输入并不匹配。

把问题放大来看:用户连续执行了两次搜索——先搜 "London",紧接着搜 "Paris"。然而,第二个请求("Paris")的响应先于第一个请求("London")返回,而组件没有正确处理这种乱序。从 API 视角看,响应乱序是正常现象:例如查询被路由到不同的服务器,其中一台负载较高、响应较慢。竞态条件的危害远不止"显示错误"——本节的例子只是视觉瑕疵,但在实际系统中它可能导致数据覆盖、重复提交等严重后果。

关于竞态条件的修复策略(如请求序号校验、忽略过期响应等),教程本身不展开讲解,而是聚焦于"如何在测试中捕获它们"。仓库中的 React 组件示例 AutocompleteField.tsx 展示了修复思路(lastQueryRef 与 lastSuccessfulQueryRef 的比对),可作为参考。

教程结构:一个贯穿始终的 queue 函数

整个教程围绕一个异步包装函数 queue 展开。它的规格如下:

  • 按序 resolve:函数返回的 Promise 必须按调用顺序依次 resolve——第一个调用先于第二个、第二个先于第三个……
  • 禁止并发:一次只能有一个调用在飞行中,后续调用必须等待前一个调用结束后再触发。

在后续数节中,queue 的实现代码是"被测试对象",我们只需要测试它,不需要修改它。为了支撑逐步深入的测试,仓库的 snippets.mjs 中提供了 queue 的多个历史版本(queueCodeV0 至 queueCodeV5),每个版本恰好能被"之前"的测试通过、被"之后"的测试击败:

版本 实现思路 能通过的测试
v0 完全不排队,直接返回原函数 仅原始单元测试
v1 仅记录"上一个请求",等待其完成 unit + 第 1 部分
v2 用数组记录所有历史请求,Promise.all 等待 unit + 第 1、2 部分
v3 改进的数组版本(防止竞态清空) unit + 第 1、2、3 部分
v4 串行链式执行,兼顾异常 全部教程测试
v5 完整队列实现(含异常处理) 所有测试(含扩展)

这份"版本—测试"矩阵并非杜撰,它直接来自 snippets.spec.mjs 中的 greenTests 配置,并且该测试文件会真实地把每个版本与每个测试组合起来运行 vitest 验证通过/失败结果。这也是本教程最大的特点:每一个练习的答案都被自动化验证过。

第 1 步:写出你的第一个竞态条件测试

现有测试及其局限

queue 已经有一个简单的单元测试,它验证"结果按调用顺序到达":

test('should resolve in call order', async () => {
  // Arrange
  const seenAnswers = [];
  const call = jest.fn().mockImplementation((v) => Promise.resolve(v));

  // Act
  const queued = queue(call);
  await Promise.all([queued(1).then((v) => seenAnswers.push(v)), queued(2).then((v) => seenAnswers.push(v))]);

  // Assert
  expect(seenAnswers).toEqual([1, 2]);
});

细看就能发现它的软肋:call 只是 Promise.resolve(v),Promise 立即完成,.then() 回调根本不会把控制权交还给事件循环。换句话说,这个测试只是在验证"JavaScript 事件循环按序处理 Promise"——而这本来就是成立的。它完全没有制造"两个并发调用、响应乱序到达"的机会。

引入 fc.scheduler()

要让测试真正有能力暴露竞态条件,第一步是用 fast-check 的 runner 把测试包起来:

test('should resolve in call order', async () => {
  await fc.assert(fc.property(fc.scheduler(), async (s) => { // <-- added
    // ...unchanged code...
  }));                                                            // <-- added
});

这里的关键是 fc.scheduler()——它生成一个 Scheduler 实例,能够重排异步操作的完成顺序。其底层实现见 scheduler.ts:scheduler() 接收可选的 SchedulerConstraints(可注入 act 包装函数),返回一个 SchedulerArbitrary,运行时会为每个属性用例生成一个调度器,并在不同用例间随机化任务的释放顺序。

改造测试的核心思想是:不改变 API 的返回值,只改变它返回的时机。把"何时 resolve"的控制权交给调度器,为此使用调度器暴露的 scheduleFunction 方法——它将任意异步函数包装成"受控版本":

const queued = queue(s.scheduleFunction(call));

调用 s.scheduleFunction(call) 时,底层会立即触发对 call(...) 的调用(只是推迟其 resolve),并把产生的 Promise 注册进调度器。之后必须显式"释放"这些任务,通常用 waitAll 或 waitFor 等待它们完成:

// 方式一:waitFor(推荐)
const pendingQueries = [];
const queued = queue(s.scheduleFunction(call));
pendingQueries.push(queued(1).then((v) => seenAnswers.push(v)));
pendingQueries.push(queued(2).then((v) => seenAnswers.push(v)));
await s.waitFor(Promise.all(pendingQueries));

// 方式二:waitAll(更简单,但有前提)
const queued = queue(s.scheduleFunction(call));
queued(1).then((v) => seenAnswers.push(v));
queued(2).then((v) => seenAnswers.push(v));
await s.waitAll();

两种等待方式在"第一批次"场景下都能工作,但**waitFor 更适合本例**:waitAll 要求"调用 waitAll 的那一刻,所有任务都已被调度",一旦代码稍延迟触发 call,waitAll 就可能等待得不够;而 waitFor 可以等待"尚未被调度的任务"(在 Scheduler.ts 的接口注释中,waitFor 明确说明它"能等待尚未调度就传入的任务",supertest 这类框架正是依赖这一点)。

结论

将 queue 的参数替换为 s.scheduleFunction(call) 后,fast-check 会在多个用例中尝试不同的 resolve 顺序,从而让 v0(完全不排队的实现)暴露"结果乱序"的缺陷。这也是教程第 1 部分的答案,见 snippets.mjs 中的 queueBasicPBTSpecCode。

第 2 步:更接近真实使用——任意数量的调用

上一版测试只覆盖了 2 个调用,而真实世界中并发请求可能有 3、4 甚至更多。教程的第二次迭代把"固定 2 次调用"升级为"任意次数调用",推荐使用 fc.integer 生成调用次数:

test('should resolve in call order', async () => {
  await fc.assert(fc.property(fc.scheduler(), fc.integer({min: 1, max: 10}), async (s, numCalls) => {
    // Arrange
    const pendingQueries = [];
    const seenAnswers = [];
    const expectedAnswers = [];
    const call = vi.fn().mockImplementation((v) => Promise.resolve(v));

    // Act
    const queued = queue(s.scheduleFunction(call));
    for (let id = 0; id !== numCalls; ++id) {
      expectedAnswers.push(id);
      pendingQueries.push(queued(id).then((v) => seenAnswers.push(v)));
    }
    await s.waitFor(Promise.all(pendingQueries));

    // Assert
    expect(seenAnswers).toEqual(expectedAnswers);
  }))
});

选择 integer 而非复杂输入的理由:queue 的设计对输入值不敏感(输入从不被其逻辑消费),因此用从 0 开始的整数最便于调试——失败时一眼就能看出乱序发生在哪个 id 上,而不是面对 123、45 这样的随机值。

当然也有替代方案,教程给出了另外两种同样可行的生成策略:

  • fc.array(fc.nat(), {minLength: 1}):直接生成一组要发起的调用 id;
  • fc.func(fc.boolean()):生成一个决定"何时停止发调用"的函数。

本步的目标是让 v1 实现(仅等待上一个请求)暴露缺陷:当 3 个以上调用几乎同时发起时,"只等上一个"的链式方案会因顺序错乱而失败。

第 3 步:多批次调用——认识 scheduleSequence

前面的测试把所有请求在同一时刻同步发出,相当于"单一批次"。但真实应用常常是"发一批、等一等、再发一批"。第三次迭代引入 scheduled sequences(调度序列),用 fc.array(fc.integer({min: 1, max: 10}), {minLength: 1}) 生成多个批次:

test('should resolve in call order', async () => {
  await fc.assert(fc.property(fc.scheduler(), fc.array(fc.integer({min: 1, max: 10}), {minLength: 1}), async (s, batches) => {
    // Arrange
    const pendingQueries = [];
    const seenAnswers = [];
    const expectedAnswers = [];
    const call = vi.fn().mockImplementation((v) => Promise.resolve(v));

    // Act
    const queued = queue(s.scheduleFunction(call));
    let lastId = 0;
    const { task } = s.scheduleSequence(batches.map((batch, index) => {
      return {
        label: `Fire batch #${index + 1} (${batch} calls)`,
        builder: async () => {
          for (let id = 0; id !== batch; ++id, ++lastId) {
            expectedAnswers.push(lastId);
            pendingQueries.push(queued(lastId).then((v) => seenAnswers.push(v)));
          }
        },
      }
    }));
    await s.waitFor(task);
    await s.waitFor(Promise.all(pendingQueries));

    // Assert
    expect(seenAnswers).toEqual(expectedAnswers);
  }))
})

scheduleSequence 的语义

scheduleSequence(sequenceBuilders, act?) 返回 { done, faulty, task }:

  • 序列内的项目必须按序执行——前一项没有结束,后一项绝不会开始;
  • 序列内项目之间可以被其他已调度的任务穿插(fast-check 会把序列中的片段与其他调度任务交错执行);
  • 一旦序列中的某个任务开始运行,调度器会等待它完全结束,才会解除其他任务的排队;
  • task 是一个 Promise,await 它即可知道整个序列是否执行完毕(done 表示全部成功,faulty 表示其中某项抛错)。

上述语义与 Scheduler.ts 的接口注释完全一致。完整的 SchedulerSequenceItem 类型见同文件 L139-L157,每一项既可以是 { builder, label, metadata? } 对象,也可以直接是一个返回 Promise 的函数。

非批次替代方案

如果不使用批次,还可以用 s.schedule(...) 手工模拟"异步触发":把"发起调用"这个动作本身也变成一个被调度的任务。

for (let id = 0; id !== numCalls; ++id) {
  pendingQueries.push(
    s
      .schedule(Promise.resolve(`Fire the call for ${id}`))
      .then(() => {
        expectedAnswers.push(id);
        return queued(id);
      })
      .then((v) => seenAnswers.push(v)),
  );
}

注意:与批次方案不同,这种方式不保证 id 的先后顺序,因此需要把 id 写进被调度 Promise 的值里,以便失败时在报告中定位。该方案的目标是让 v2 实现(Promise.all(pastQueries) 版本)暴露缺陷——当调用被分成多批、且批与批之间有时间间隔时,v2 会在清除 pastQueries 的时机上出错。

第 4 步:补全规格——检测"并发调用"

前几步都聚焦在规格的第一条(按序 resolve),却忽略了第二条:并发调用是不允许的。第四次迭代补上这一检测:在 scheduledCall 外包一层监控,记录"是否在上一次调用尚未 resolve 时就发起了新调用"。

const scheduledCall = s.scheduleFunction(call);
let concurrentQueriesDetected = false;
let queryPending = false;
const monitoredScheduledCall = (...args) => {
  concurrentQueriesDetected ||= queryPending;
  queryPending = true;
  return scheduledCall(...args).finally(() => (queryPending = false));
};

// Act
const queued = queue(monitoredScheduledCall);
// ... 同样的多批次 scheduleSequence 逻辑 ...

// Assert
expect(seenAnswers).toEqual(expectedAnswers);
expect(concurrentQueriesDetected).toBe(false);

这个包装器的逻辑是:调用进入时若 queryPending 已为 true,说明前一个调用还没结束——立刻记下 concurrentQueriesDetected = true;随后置位 queryPending,并在被调度调用 settle 时复位。这样,任何"在旧调用完成前触发新调用"的行为都会被捕获。该测试会让 v3 实现暴露缺陷(它虽然对数组做了竞态保护,但本质上仍是并发触发),对应代码见 snippets.mjs 的 missingPartPBTSpecCode。

收尾:从 queue 到真实项目

关于同步调用与异常

教程收尾章节(wrapping-up.mdx)进一步讨论了两个"锦上添花"的规格点:

同步调用(synchronous calls):如果要求 queue 的第一个调用同步触发(立即执行 fun(...args),而不是排队到事件循环之后),测试就可以用 waitAll 并去掉所有等待逻辑。教程给出了一组逐步改进的 fireCall 实现:从简单的 previous = previous.then(() => call())(一切排队、不同步触发),到"首调用同步触发"的朴素尝试(第二次调用仍有问题),再到引入 callId 的较完整版本。这些实现说明一个道理——"同步触发 + 严格串行"的边界条件非常容易写错,正是竞态测试的价值所在。

异常支持(support exceptions):异步代码必然可能抛错。增强测试只需让 mock 的 call 按需 reject:

const call = vi.fn().mockImplementation((v) => isFailure(v) ? Promise.reject(v) : Promise.resolve(v));

其中 isFailure 由 fc.func(fc.boolean()) 生成。相应地,期望值也要区分成功/失败:

expectedAnswers.push(isFailure(lastId) ? `failure:${lastId}` : `success:${lastId}`);
queued(lastId).then(
  (v) => seenAnswers.push(`success:${v}`),
  (v) => seenAnswers.push(`failure:${v}`),
);

核心思想不变:无论成功还是失败,都应该按序到达。queue.v5.js(snippets.mjs 的 queueCodeV5)是教程中唯一能同时通过"异常版"与"waitAll 版"扩展测试的实现,这与 snippets.spec.mjs 中的验证矩阵一致。

扩展到用户界面测试

教程的模式完全可以平移到 UI 组件。仓库的 examples/005-race 目录提供了多个可运行示例:

  • autocomplete:用 s.scheduleFunction 包装 search API、用 s.scheduleSequence 模拟"逐字符输入"事件、用 s.waitOne() 逐条释放查询并断言"建议始终匹配当前输入",测试代码见 main.spec.tsx。该测试本质上是把教程的"按序、不可并发"思想映射到"输入事件序列 × 响应到达序列"的组合上。
  • todolist:把调度器与模型化测试(model-based testing)结合,通过 fc.scheduledModelRun 让模型执行也走调度器,同时用 s.scheduleFunction 包装 fetchAllTodos、addTodo 等 API 模拟一个可乱序响应的 mock 后端,见 main.spec.tsx。

在 UI 场景下还有两个实用技巧:

  1. act 包装:fc.scheduler({ act }) 可让所有被调度的任务在正确的上下文中执行(例如 React 的状态更新必须发生在 act 内)。act 也可以按任务局部指定,详见 race-conditions.md 的 "Wrapping calls automatically using act" 一节。
  2. 原生定时器调度:当被测代码依赖 setTimeout/setInterval 时,可以用自定义 act 把"推进定时器"本身也注册为调度任务,配合 jest.useFakeTimers() 使用(同样见 race-conditions.md 的 "Scheduling native timers" 一节)。

附:Scheduler API 速查

下表综合 race-conditions.md 与 Scheduler.ts 接口定义,供实战查阅:

方法 签名要点 作用
schedule (task: Promise<T>, label?, metadata?, act?) => Promise<T> 包装既有 Promise,由其决定何时 resolve
scheduleFunction (asyncFunction, act?) => (...args) => Promise<T> 包装异步 API,重排其返回的 Promise 的完成顺序
scheduleSequence (builders, act?) => { done, faulty, task } 声明按序执行的调度序列
waitNext (count, act?) => Promise<void> 恰好释放 count 个已调度任务(v4.2.0 起,替代 waitOne)
waitIdle (act?) => Promise<void> 等待调度器完全空闲(含递归调度的任务,v4.2.0 起,替代 waitAll)
waitFor (unscheduledTask: Promise<T>, act?) => Promise<T> 等待"恰好足够"的任务以 resolve 传入的 Promise,可等待尚未调度的任务
report () => SchedulerReportItem[] 输出所有已调度任务的状态(resolved/rejected/pending)及执行顺序
count () => number 待调度任务数(v4.2.0 起弃用)

此外,fc.schedulerFor(<a href="https://link.gitcode.com/i/88ed79e8ec00167d847ab2659f44aa3f" target="_blank">1, 3, 2])(或模板字符串形式)可以构造固定释放顺序的调度器,用于复现特定失败场景(实现见 [scheduler.ts)。

注意版本差异:教程正文与示例中的 waitAll/waitOne 在 v4.2.0 起已被标记为 deprecated(见 Scheduler.ts 的注释),新代码建议分别改用 waitIdle 与 waitNext(1);而 waitFor 始终是官方推荐的主力等待方式,教程与示例均大量使用。

结语

从"一个只测事件循环的单元测试"到"能暴露并发调用缺陷的属性测试",本教程的演进路线展示了 fast-check 检测竞态条件的完整方法论:用 fc.scheduler() 接管异步任务的释放时机 → 用 scheduleFunction 包装被测 API → 用 waitFor/waitIdle 精确等待 → 用 scheduleSequence 编排多批次事件 → 用监控包装器捕获非法并发。这套模式不依赖任何具体框架,可以迁移到 queue 之类的工具函数、React/Vue 组件乃至模型化测试中,是保障异步应用可靠性的实用武器。

登录后查看全文
fast-check