Vitest 位置式 Mock 队列漂移:新增一条 SQL 让六个测试全红
某天给一个后台管理系统的列表接口加了权限范围查询——仅此一条 SQL,加在 handler 最前面。跑测试,六个绿灯全变红。报错不是 assertion failed,而是一堆 TypeError: Cannot read properties of undefined。一时没反应过来,以为是自己写错了权限逻辑,反复对照,代码没问题。问题出在测试的 mock 层。
现象
列表接口的测试文件里有这样的 mock 设置:
mockQuery
.mockResolvedValueOnce([[categoryRows], []]) // 1. 分类
.mockResolvedValueOnce([[listRows], []]) // 2. 列表主查询
.mockResolvedValueOnce([[orderAggs], []]) // 3. 聚合统计
.mockResolvedValueOnce([[dimRows], []]) // 4. 维度明细
.mockResolvedValueOnce([[{ total: 11 }], []]) // 5. 分页总数handler 里我新增了一条 SELECT data_scope FROM roles WHERE id IN (?) 作为第一步权限查询。接口逻辑完全正确,但测试全挂了:body.data[0].name 是 undefined,因为 body.data 现在拿到的是分类数组,不是列表行。
根因
mockResolvedValueOnce 是 FIFO 队列——每调一次就推一个值,handler 按发出 SQL 的顺序依次消费。
原来 handler 发 5 条 SQL,mock 准备了 5 个值,位置一一对应。新增权限查询后,handler 发 6 条 SQL,位置 1 被新查询消费,原来准备给「分类查询」的值现在喂给了「权限查询」,原来准备给「列表查询」的值喂给了「分类查询」……整个队列偏移一格,每个测试都拿到错误的行数据。
只要 handler 在任意位置插入、删除或重排一条查询,现有全部测试的 mock 队列就错位。维护成本随测试数量线性增长——15 个测试文件 × 每次 handler 改动 = 每次都要手动重排所有 mock。
正解
把 mockResolvedValueOnce 链换成一个基于 SQL 文本前缀的 mockImplementation:
mockQuery.mockImplementation((sql: string) => {
if (sql.startsWith('SELECT data_scope FROM roles')) {
return Promise.resolve([[{ data_scope: 'self' }], []])
}
if (sql.startsWith('SELECT code, name, category FROM')) {
return Promise.resolve([[categoryRows], []])
}
if (sql.startsWith('SELECT c.id, c.name')) {
return Promise.resolve([[listRows], []])
}
if (sql.startsWith('SELECT o.customer_id, COUNT(*) AS order_count')) {
return Promise.resolve([[orderAggs], []])
}
if (sql.startsWith('SELECT COUNT(*) AS total FROM')) {
return Promise.resolve([[{ total: 11 }], []])
}
// 兜底:强制暴露未知查询
throw new Error(`Unexpected SQL: ${sql.slice(0, 80)}`)
})每个分支声明「我回答哪条查询」,而不是「我是第几个」。handler 新增查询时,只需要在 mock 里加一个对应的 if 分支,其余测试完全不受影响。
关于前缀的选取:
- 精确匹配:SQL 格式化改动就会失效
- 正则:
SELECT .* FROM orders容易跨越多条相似查询 - 前缀(取 SQL 的前几个有区分度的词):对空白符和注释变动有容忍,且不同查询的开头通常足够区分
末尾的 throw 至关重要——它把「handler 新增了一条测试没覆盖的查询」从静默 undefined 变成即时 test failure,强制测试作者主动为新查询补 mock。
每个 mock 分支与具体 SQL 绑定,而非与位置绑定。handler 内部查询顺序随意变化,测试 mock 无需同步修改。加兜底 throw 保证新查询被显式捕获。
每条 pool.query() 的 SQL 字符串加注释头,例如 /* roles-scope */,前缀匹配变成 sql.startsWith('/* roles-scope */') ——既 grep 友好,又让 mock 与具体列名解耦。
对有多种 dataScope 变体的测试,提取 setupMocks({ dataScope: 'self' }) 工厂函数,把 mock 实现收进去,各测试只 override 差异部分。
一句话外卖
用
mockImplementation+ SQL 前缀派发替代mockResolvedValueOnce链,让测试 mock 与 handler 内部查询顺序解耦,一次投入,永久免疫队列漂移。