#测试#JavaScript#工程实践

从零实现一个前端单元测试框架

大家日常写测试时,很多 API 就像这样:

test('加法正确', () => {
  expect(sum(3, 7)).toBe(10)
})

再复杂一点,会有 beforeEachjest.fn()vi.mock()、测试报告、覆盖率和 watch mode。工具把细节收得很好,带来的副作用是:test 何时真正开始跑、异步为什么能等住、Mock 怎样留下调用记录,反而不容易说清。

时至今日,提到前端测试,大家可能会想到 Jest、Vitest,或者 Node 自带的 node:test。但如果回到最朴素的理解,它们其实都在回答同一组问题:测试怎样登记、怎样执行,失败怎样一路传回报告,Mock 又从哪里插进来。

我们先不看复杂实现,用一个 CommonJS 小框架把共同骨架摊开。

先看最终要跑的测试

先从使用者视角出发。假设有一个工具模块:

// math.js
function sum(a, b) {
  return a + b
}

function sumAsync(a, b) {
  return Promise.resolve(sum(a, b))
}

module.exports = { sum, sumAsync }

测试文件希望写成这样:

// math.test.js
const { sum, sumAsync } = require('./math')

test('sum 可以相加', () => {
  expect(sum(3, 7)).toBe(10)
})

test('sumAsync 可以等待', async () => {
  await expect(sumAsync(3, 7)).resolves.toBe(10)
})

这里最容易产生的误会是:test() 在“运行测试”。

其实它更像餐厅前台拿到一张订单。测试文件被加载时,test('sum 可以相加', callback) 立刻执行,但它先把标题和回调登记起来,不急着调用回调。等所有测试文件都加载完,Runner 再按顺序处理这些订单。

如果 test() 登记完就立刻执行,测试文件的加载顺序、后续的钩子函数和最终统计都会缠在一起。先登记,后执行,Runner 才有地方安排它们。

测试框架 = 用例登记 + 统一执行 + 失败上报 + 结果汇总

测试 Runner:先登记,再执行

先做一个最小的用例仓库。test 只负责往里面放数据:

// framework/runner.js
const cases = []

function test(title, callback) {
  cases.push({ title, callback })
}

async function run() {
  const results = []

  for (const { title, callback } of cases) {
    const startedAt = performance.now()

    try {
      await callback()
      results.push({
        title,
        status: 'passed',
        duration: performance.now() - startedAt,
      })
      console.log(`✓ ${title}`)
    } catch (error) {
      results.push({
        title,
        status: 'failed',
        error,
        duration: performance.now() - startedAt,
      })
      console.error(`✕ ${title}`)
      console.error(error.stack ?? error)
    }
  }

  return results
}

module.exports = { test, run }

可以看到,虽然代码不长,但核心机制已经出现了:await callback() 把同步和异步收进同一条路径。普通函数直接结束,Promise 则等到 fulfilled 或 rejected。超时、并发、done 回调这些能力,都是继续回答同一个问题:这条用例到底什么时候算结束。

加载测试文件和执行测试用例很容易混淆,画出来会直观一些:

flowchart LR
    A["命令行启动"] --> B["注入全局 API"]
    B --> C["加载 *.test.js"]
    C --> D["test() 登记用例"]
    D --> E["run() 依次执行回调"]
    E --> F{"回调是否抛错?"}
    F -->|"否"| G["记录通过"]
    F -->|"是"| H["记录失败与堆栈"]
    G --> I["输出汇总"]
    H --> I

require(testFile) 只负责登记,run() 才会调用回调。这个边界后面会不断出现:钩子、筛选、并发和报告都挂在这里。

断言库:成功时沉默,失败时抛错

Runner 知道怎样执行回调了,但它还不知道“正确”是什么。这个判断交给断言库。

最简单的 expect 可以只有一个 matcher:

// framework/expect.js
function expect(actual) {
  return {
    toBe(expected) {
      if (!Object.is(actual, expected)) {
        throw new Error(
          `Expected ${String(actual)} to be ${String(expected)}`
        )
      }
    },
  }
}

module.exports = { expect }

toBe 使用 Object.is,因此它的语义是“是不是同一个值”。例如两个内容相同但分别创建的对象不会通过:

expect({ name: 'Suri' }).toBe({ name: 'Suri' }) // 失败

这不是框架故意刁难,而是 JavaScript 的对象引用本来就不同。要比较对象结构,需要另一个 matcher,例如用 Node.js 自带的 assert.deepStrictEqual 包一层:

const assert = require('node:assert/strict')

function toEqual(actual, expected) {
  try {
    assert.deepStrictEqual(actual, expected)
  } catch {
    throw new Error(
      `Expected ${JSON.stringify(actual)} to deeply equal ` +
      JSON.stringify(expected)
    )
  }
}

为什么失败要 throw,而不是 return false

因为 Runner 已经把“回调正常结束视为通过,回调抛错视为失败”定义成统一协议。断言失败时抛错,错误会自然沿着测试回调向上传到 Runner;用例标题、错误堆栈、耗时这些上下文,也能在同一个地方统一记录。断言库不需要认识 Runner,Runner 也不用认识每一种断言,它们只通过 Error 这个很朴素的边界合作。

Jest 的 expect 体系比这里丰富得多,包括非对称匹配、快照、异步 matcher 和自定义 matcher。不过“matcher 不满足预期就让当前测试失败”这条协议没有变,详见 Jest Expect 文档

Promise 断言只是把等待放进链条

前面的 sumAsync 返回 Promise。我们希望测试读起来是:

await expect(sumAsync(3, 7)).resolves.toBe(10)

它不需要另一套断言模型,只需要先等待实际值,再复用已有的 toBe

function expect(actual) {
  return {
    toBe(expected) {
      if (!Object.is(actual, expected)) {
        throw new Error(`Expected ${actual} to be ${expected}`)
      }
    },

    resolves: {
      async toBe(expected) {
        const resolved = await actual
        if (!Object.is(resolved, expected)) {
          throw new Error(`Expected ${resolved} to be ${expected}`)
        }
      },
    },
  }
}

这里有一个很常见的坑:

// 这段代码可能在 Promise 失败前就让测试结束了
expect(sumAsync(3, 7)).resolves.toBe(10)

resolves.toBe() 本身会返回 Promise。只有把它 awaitreturn 出去,Runner 的 await callback() 才知道该等多久。异步测试的关键不是“代码里出现了 Promise”,而是要把 Promise 接回 Runner 的等待链条。

自动注入:为什么测试文件里没有 import

现在有了 testexpect,但测试文件怎么拿到它们?最直白的做法是每个文件手动引入:

const { test } = require('./framework/runner')
const { expect } = require('./framework/expect')

能用,只是有点吵。测试框架通常会在加载测试文件前,把这些 API 放到全局对象上:

// framework/cli.js
#!/usr/bin/env node

const path = require('node:path')
const { test, run } = require('./runner')
const { expect } = require('./expect')

global.test = test
global.expect = expect

const files = process.argv.slice(2)
for (const file of files) {
  require(path.resolve(process.cwd(), file))
}

run().then((results) => {
  const passed = results.filter((item) => item.status === 'passed').length
  const failed = results.length - passed

  console.log(`\nTests: ${passed} passed, ${failed} failed, ${results.length} total`)
  process.exitCode = failed === 0 ? 0 : 1
})

此时运行:

node framework/cli.js math.test.js

会先加载 math.test.js,里面的两次 test() 只是在 cases 数组里登记两条记录;文件加载完成后才进入 run()。这就是测试 API “看起来凭空存在”的最小解释。

当然,真实框架会尽量减少全局污染,也会为每个测试文件提供隔离环境。Jest 和 Vitest 可以配置全局 API;node:test 则常见 import { test } from 'node:test' 的显式写法。这里直接写 global,只是为了把机制露出来,不建议把它原样带进业务项目。

函数 Mock:给调用留一份账本

测试纯函数很轻松:输入固定,输出大多也固定。现实里的函数却经常依赖网络、时间、随机数、埋点或另一个模块。单元测试不该为了验证自己的业务逻辑,顺带真的发一次请求、真的生成一串随机 ID。

这时需要 Mock。先别把它想得太神秘,函数 Mock 的核心就是一个代理函数加一本账:

function fn(implementation = () => undefined) {
  function mockFn(...args) {
    mockFn.mock.calls.push(args)

    try {
      const value = implementation(...args)
      mockFn.mock.results.push({ type: 'return', value })
      return value
    } catch (error) {
      mockFn.mock.results.push({ type: 'throw', value: error })
      throw error
    }
  }

  mockFn.mock = {
    calls: [],
    results: [],
  }

  return mockFn
}

使用时,原实现仍然可以保留,额外的 mock 字段只负责记录历史:

const sum = (a, b) => a + b
const sumMock = fn(sum)

sumMock(3, 7)

expect(sumMock.mock.calls.length).toBe(1)
expect(sumMock.mock.calls[0]).toEqual([3, 7])
expect(sumMock.mock.results[0].value).toBe(10)

如果把 Mock 想象成快递柜的出入记录,会比较直观:快递员(调用方)还是把包裹交给原来的收件流程(implementation),但柜子额外记下了谁什么时候放了什么。这个比喻只解释“调用如何被记录”,不解释模块级替换。toHaveBeenCalledTimes 检查的是记录条数,toHaveBeenCalledWith 检查的是其中一条记录的参数。

这里也顺手校准一个容易写错的语义。toHaveBeenCalledWith(3, 7) 的意思通常是“至少有一次调用的参数等于 [3, 7]”,不是“每次调用都必须是 [3, 7]”。因此最小实现应当用 some

function toHaveBeenCalledWith(mockFn, expectedArgs) {
  const matched = mockFn.mock.calls.some((args) => {
    try {
      assert.deepStrictEqual(args, expectedArgs)
      return true
    } catch {
      return false
    }
  })

  if (!matched) {
    throw new Error('Expected mock function to receive the expected arguments')
  }
}

这个小细节其实很能说明测试框架的难处:框架代码未必复杂,难的是把一句自然语言 API 的边界定义准确。

模块 Mock:在依赖被加载前换掉它

函数 Mock 是手里已经有一个函数,再给它包一层。模块 Mock 要处理的是另一件事:被测模块内部 require('uuid') 了依赖,我们不想让它拿到真实的 uuid

在 CommonJS 的最小模型里,require() 加载过的模块会被缓存。可以在目标模块加载前,把缓存位置替换成自己的导出对象:

// user.test.js
const uuidPath = require.resolve('uuid')

require.cache[uuidPath] = {
  id: uuidPath,
  filename: uuidPath,
  loaded: true,
  exports: {
    v4: () => 'fake-user-id',
  },
}

const { createUser } = require('./user')

test('createUser 使用可预测的 id', () => {
  expect(createUser({ name: 'Suri' })).toEqual({
    id: 'fake-user-id',
    name: 'Suri',
  })
})

这段代码的因果关系只有两步:

  1. user.js 还没加载时,先让 uuid 的缓存指向测试替身。
  2. user.js 再执行 require('uuid') 时,拿到的就是替身导出。

因此顺序不能反过来。如果先 require('./user')user.js 已经把真实 uuid 保存下来了,后面再改缓存也影响不到已经发生的绑定。

Node.js 官方文档说明了 CommonJS 模块会按解析后的文件名缓存;这正是这个演示能成立的前提。Modules: Caching。不过缓存替换只是为了讲清模型,真实项目应优先使用 Jest/Vitest 提供的 mock API。

也可以换个角度看这件事:Mock 并不是“伪造一个值”这么简单,它是在被测代码和外部世界之间插入一扇门。门后面可以是固定返回值、记录调用的代理,或者会故意抛错的异常场景。测试由此获得可控性。

到了 ESM 时代,Mock 不再只是改缓存

上面那段 require.cache 代码很适合解释 CommonJS,但它不是今天所有模块 Mock 的通用实现。前端项目大多已经写成 ESM:

import { v4 as uuid } from 'uuid'

ESM 的静态 import 会在模块正文执行前完成解析和链接。也就是说,下面这种“先执行一段注册 Mock 的代码,再让静态 import 生效”的直觉,本身就不成立:

// 静态 import 已经先于这里的代码完成
vi.mock('uuid', () => ({ v4: () => 'fake-user-id' }))
import { v4 } from 'uuid'

不同工具对这个限制给出了不同实现,但都在解决同一个时序问题:替身必须在被测模块读取依赖之前准备好。

环境函数 Mock模块 Mock 的关键机制
手写 CommonJS Runner代理函数记录 callsrequire() 前替换 require.cache,仅用于理解模型
Jest + CommonJSjest.fn()jest.mock() 可在模块加载前注册替身
Jest + ESMjest.fn()jest.unstable_mockModule() 注册,再用动态 import() 加载被测模块
Vitest + ESMvi.fn()Vite 的转换器将 vi.mock() 提升,并把静态 import 改造成可在 Mock 注册后加载的形式
Node node:testt.mock.fn() / t.mock.method()函数和方法 Mock 已内置;模块 Mock 仍属于 Early development,需要显式开启实验 flag

这张表不是让人记 API,而是为了避免一个常见误判:jest.mockvi.mock 和手改 require.cache 的表面效果相似,背后却不一定走同一条路。尤其在 ESM 项目里,导入时机、模块转换和隔离策略都是框架工作的一部分。

例如,Jest 当前的 ESM 文档明确要求先调用 jest.unstable_mockModule(),再用动态 import() 加载依赖;Vitest 则通过 Vite 转换器处理 vi.mock() 的提升。两种方式不同,目的相同:不能让真实依赖先一步进入模块图。

// Jest ESM:先登记替身,后动态加载
import { jest } from '@jest/globals'

jest.unstable_mockModule('uuid', () => ({
  v4: jest.fn(() => 'fake-user-id'),
}))

const { createUser } = await import('./user.js')

因此,前文的 CommonJS 版本仍然值得保留,它把“模块缓存”和“加载时序”这两个基础概念讲得很直白;只是落到真实前端工程时,应根据项目的模块系统选择 Jest、Vitest 或 Node 原生 Runner 的 API,而不是照搬缓存替换。

一个小框架还缺什么

到此为止,已经有了能运行的骨架:

测试文件
  ↓ 登记
用例列表
  ↓ 调度
Runner
  ↓ 执行
断言 / Mock
  ↓ 抛错或记录
测试报告

它可以跑同步和异步用例,可以比较值,可以记录函数调用,也能用 CommonJS 缓存解释模块替换。但离日常使用的 Jest、Vitest 或 node:test 还差很远,主要差在四个方向:

  • 隔离:每个测试文件应当有自己的模块缓存、全局对象和清理逻辑,否则前一个用例可能污染后一个。
  • 调度:beforeEachafterEach、超时、跳过、并发、重试,都是在管理“何时执行”和“执行后怎样收场”。
  • 模块图:要同时面对 CommonJS、ESM、TypeScript/JSX 转换,以及 Mock 在导入前注册这个严格的时序约束。
  • 诊断:真实框架要格式化 diff、映射源码位置、过滤堆栈、生成覆盖率,失败时让人尽快看懂发生了什么。

这四类能力看起来像附加功能,实际上决定了框架能不能进入团队日常。前面几十行代码解决的是“能跑”,后面的工程工作解决的是“出问题时有人愿意用它”。

小结

写到这里,testexpectmock 这些 API 已经没那么神秘了。

  • test() 先登记,Runner 再统一执行。这给异步等待、钩子和统计留出了位置。
  • 断言失败时抛 Error,错误自然会回到 Runner;成功则不需要额外打扰谁。
  • 函数 Mock 是“代理函数 + 调用账本”。模块 Mock 更麻烦些:替身必须赶在依赖被读取前准备好,CommonJS 和 ESM 的处理方式也不同。
  • Jest、Vitest 这类框架真正难做的部分,往往是隔离、调度、模块图和失败诊断。

下次看到 expect(mock).toHaveBeenCalledWith(...),可以把它还原成一件很具体的事:框架给函数包了一层,记下每次调用的参数,最后翻账本核对。没有黑魔法,只有一组小协议彼此咬合,最后才长成了可靠的工程工具。

参考资料