在做异步单元测试的时候,大家可能都遇到过测试用例跑着跑着就卡住不动了,最后报超时。明明只是把数据库、接口这些外部依赖mock掉了,怎么反而把自己坑了呢?今天就来聊聊这个“死锁”问题。
一、会遇到的场景
假设咱们要测一个“根据用户ID获取用户名”的函数。这个函数对外的表现很简单,但内部会调用数据库的查询方法。为了不让测试真的连数据库,我们就把这个查询方法mock成一个假函数。可是,测试一跑,等了半天没反应,最后终端红了,给出一个大大的超时提示。你盯着屏幕,心想:到底哪里出了问题?
这种场景在异步单元测试里特别常见。我们以为“mock掉外部依赖”就能让测试变快变简单,结果往往是测试挂得更诡异了。尤其当你第一次遇到Jest的Exceeded timeout of 5000 ms时,心里一定很崩溃。
二、死锁是怎么发生的
要搞清楚死锁,得先明白什么是“异步依赖”。一个异步函数在等待另一个异步函数的结果,而另一个异步函数又在等一个永远不会完成的东西,双方就这么相互等着,跟死锁一样。在编程里,这通常不是真正的线程锁,而是“Promise一直不结束”造成的等待。
这里需要简单补一下基础知识:在JavaScript里,一个Promise可以处于“进行中”和“已定型”两种状态。如果它一直停在“进行中”,那么任何await它的代码都会一直等着,永远不会往下一行走。为什么?因为事件循环的微任务队列里压根没有新的任务,await后面的代码自然就排不上队。换句话说,没有产生新的微任务,await就永远不会被唤醒。
用一个生活化的比喻:你去餐厅点了一份菜,厨师答应做,但一直没动静。你坐在那儿干等,既不能走,也不能催,因为服务员也消失了。这个“菜”就是一个永远pending的Promise,而“你”就是那个await。
所以,mock外部依赖的时候,最怕的就是“造了一个永远不结束的Promise”。只要mock出来的Promise不变成“已定型”,你的单元测试就会死锁。
2.1 关联知识:Jest里如何mock一个模块
Jest提供了jest.mock,它可以自动把模块里的函数替换成jest.fn()。然后咱们用mockImplementation、mockResolvedValue这些方法去配置返回内容。如果不配置,mock函数默认会返回undefined。但有些同学会自己手动写mockImplementation,这时候特别容易出错。理解了这些底层机制,再看下面的错误示例,就会觉得一切都在意料之中。
三、错误写法示例
光说理论有点抽象,咱们直接看代码。下面的示例统一使用JavaScript和Jest测试框架。
3.1 错误1:mock返回一个永远不resolve的Promise
// 技术栈:JavaScript + Jest
// 假设这是外部依赖模块:database.js
// 真实代码里它会去连数据库,现在被jest.mock替换掉
jest.mock('./database');
const db = require('./database');
const { getUserName } = require('./service');
// 错误!这个mock实现返回了一个“永不结束”的Promise
db.getUser.mockImplementation((id) => {
// 注意:new Promise里的executor函数没有调用resolve或reject
return new Promise(() => {
// 这里什么都不做,Promise就一直停在pending状态
});
});
test('获取用户名', async () => {
// 这个await永远等不到结果,测试超时
const name = await getUserName('123');
expect(name).toBe('Alice');
});
为什么会卡住?因为db.getUser返回的Promise一直处于pending状态。getUserName内部执行了await db.getUser(id),这个await后面的代码永远不会被推到微任务队列,所以测试函数也永远不会执行完,最终Jest把它判成超时。
3.2 错误2:在mock实现里又去调用其他mock
有时候为了图方便,我们把两个外部依赖互相串起来,结果就绕晕了。
// 技术栈:JavaScript + Jest
jest.mock('./userDb');
jest.mock('./logger');
const userDb = require('./userDb');
const logger = require('./logger');
const { handleUser } = require('./service');
// userDb的mock实现里调用了logger
userDb.getUserById.mockImplementation(async (id) => {
// logger.logUser内部又去调用了userDb.getUserById,形成了循环等待
await logger.logUser(id);
return { id: id, name: 'Bob' };
});
// logger的mock实现里又调用了userDb的另一个方法
logger.logUser.mockImplementation(async (id) => {
// 这里调用了userDb.getUserById,但它的mock实现里又在等logger
const user = await userDb.getUserById(id);
console.log(user.name);
});
test('处理用户', async () => {
await handleUser('123');
// 到这里就会卡住,因为getUserById在等logUser,logUser又在等getUserById
});
这种互相调用的写法一旦形成环,任何一个await都会让两边谁都无法继续。在实际业务代码中,两个模块互相调用的情况并不多,但因为mock让它们“同处一个测试环境”,所以很容易被不小心造出来。
3.3 错误3:假定时器与真实异步混用
Jest的假定时器(fake timers)能帮我们控制setTimeout,但如果用不好,也会让测试挂起。
// 技术栈:JavaScript + Jest
jest.useFakeTimers();
const { delayAndFetch } = require('./service');
const api = require('./api');
jest.mock('./api');
api.fetchData.mockResolvedValue({ data: 'hello' });
test('延迟后获取数据', async () => {
// delayAndFetch内部会先等待1秒,再调用api.fetchData
const promise = delayAndFetch();
// 没有推进假定时器,这个await永远不会结束
const result = await promise;
expect(result).toEqual({ data: 'hello' });
});
这里的问题在于:delayAndFetch内部有个setTimeout,但在假定时器模式下,时间并没有往前走。于是它内部的await永远卡在定时器那一行。更麻烦的是,这个promise已经在等待了,而我们测试里没有去“推进时间”,所以整个测试就卡死了。
四、正确写法与避坑指南
4.1 让mock返回固定的Promise
最简单的办法:不要自己创建“空Promise”,直接用Promise.resolve或Promise.reject,或者用Jest强大的mockResolvedValue。
// 技术栈:JavaScript + Jest
jest.mock('./database');
const db = require('./database');
const { getUserName } = require('./service');
// 好习惯:让mock直接返回一个已经resolve的值
db.getUser.mockResolvedValue({ id: '123', name: 'Alice' });
test('获取用户名', async () => {
const name = await getUserName('123');
expect(name).toBe('Alice');
});
4.2 使用mockRejectedValue模拟出错
如果要测“外部依赖出错时,业务代码能不能处理好”,可以用mockRejectedValue。
// 技术栈:JavaScript + Jest
db.getUser.mockRejectedValue(new Error('数据库连不上'));
test('数据库出错时返回兜底值', async () => {
// 假设getUserName内部捕获了错误并返回“unknown”
const name = await getUserName('888');
expect(name).toBe('unknown');
});
4.3 手动控制Promise的resolve
有时候,测试需要“让某个异步操作先等一会儿,再给结果”。这时我们可以自己保存resolve函数,手动决定什么时候完成。
// 技术栈:JavaScript + Jest
jest.mock('./queue');
const queue = require('./queue');
const { consumeOne } = require('./consumer');
// 用一个变量保存resolve函数
let resolveMessage;
queue.popMessage.mockImplementation(() => {
// 返回一个Promise,并把resolve留到测试里手动触发
return new Promise((resolve) => {
resolveMessage = resolve;
});
});
test('消费一条消息', async () => {
// 开始消费,但此时Promise还没有resolve
const resultPromise = consumeOne();
// 在测试中手动“送达”一条消息
resolveMessage({ id: 1, content: '谢谢惠顾' });
const result = await resultPromise;
expect(result.content).toBe('谢谢惠顾');
});
这种手动控制的方式非常强大,它把“什么时候完成”的决定权交到了测试手里。但也要注意,如果某个测试路径忘了调用resolveMessage,同样会死锁。所以建议始终在finally或者afterEach里留一个“保险”的清理逻辑。
4.4 正确使用假定时器
如果业务代码里有定时器,建议用jest.runAllTimers()或者jest.advanceTimersByTimeAsync()来推进时间,并且要记得把异步操作和定时器分开处理。
// 技术栈:JavaScript + Jest
jest.useFakeTimers();
const { delayAndFetch } = require('./service');
const api = require('./api');
jest.mock('./api');
api.fetchData.mockResolvedValue({ data: 'hello' });
test('延迟后获取数据', async () => {
// 先把定时器推进1秒
jest.advanceTimersByTime(1000);
// 现在调用service,内部的setTimeout会立即被跳过
const result = await delayAndFetch();
expect(result).toEqual({ data: 'hello' });
});
如果业务代码里的是setTimeout回调后再执行异步函数,更稳妥的方式是配合jest.advanceTimersByTimeAsync(),它会把微任务也一并跑完。
// 技术栈:JavaScript + Jest
test('延迟后获取数据(推荐)', async () => {
const promise = delayAndFetch();
// 用异步版推进定时器,内部会把await后面的微任务都运行完
await jest.advanceTimersByTimeAsync(1000);
const result = await promise;
expect(result).toEqual({ data: 'hello' });
});
这里有个小细节:普通版的advanceTimersByTime是同步的,如果定时器回调里还有异步操作,它可能来不及处理那些Promise微任务。异步版则会把微任务队列一起耗尽,所以更可靠。这一点在实际项目中很容易踩坑。
4.5 如果外部依赖是回调函数怎么办
有些老库不是Promise风格,而是使用“回调函数”传结果。这时mock就要稍微绕一下。
// 技术栈:JavaScript + Jest
jest.mock('./legacyDb');
const legacyDb = require('./legacyDb');
const { getUserById } = require('./service');
// 模拟一个回调风格的方法
legacyDb.getUser.mockImplementation((id, callback) => {
// 在下一个事件循环里执行回调,模拟真实异步
setTimeout(() => {
callback(null, { id: id, name: '老王' });
}, 0);
});
test('从回调函数里拿到用户', async () => {
// getUserById内部会把回调风格包装成Promise
const user = await getUserById('007');
expect(user.name).toBe('老王');
});
如果这里不小心不给callback传任何东西,那包装出来的Promise也会一直pending,照样死锁。所以遇到回调风格依赖,一定要记得在mock实现里调用callback。
4.6 清理和重置mock,避免状态污染
还有一个很容易被忽略的点:Jest的每个测试文件都是独立的,但如果多个测试共享同一个mock实现,状态可能互相干扰。建议在beforeEach里调用jest.clearAllMocks()来重置调用记录,在afterEach里调用jest.resetAllMocks()来清空mock实现。这个习惯能帮你避免很多莫名其妙的死锁。
4.7 注意事项清单
- 不要自己在mock实现里
return new Promise(() => {}),除非你有办法在别处唤醒它。 - 不要在mock实现中调用其他被mock的同级依赖,除非你非常清楚调用链不会形成环。
- 遇到定时器时,优先使用
jest.useFakeTimers()和配套的异步推进函数。 - 测试卡死时,先检查是不是有某个Promise一直pending。可以给
test加一个timeout提示,但治标不治本。 - 尽量让mock的职责单一:要么直接返回数据,要么抛错,要么通过回调让测试主动控制。
五、完整示例演示
咱们把上面的知识串起来,写一个稍微完整一点的测试文件。假设要测一个“用户注册后发送欢迎消息”的功能。它有两个外部依赖:一个保存用户,一个发送邮件。
// 技术栈:JavaScript + Jest
// service.js (被测代码片段)
// async function registerUser(user) {
// const saved = await saveUser(user);
// await sendWelcome(saved.email);
// return saved;
// }
jest.mock('./saveUser');
jest.mock('./sendEmail');
const { registerUser } = require('./service');
const saveUser = require('./saveUser');
const sendEmail = require('./sendEmail');
test('注册成功后发送欢迎邮件', async () => {
// 让saveUser直接返回一个“已保存”的用户
saveUser.mockResolvedValue({
id: 1,
name: '小明',
email: 'xiaoming@example.com'
});
// 让sendEmail也直接返回成功
sendEmail.mockResolvedValue(true);
// 执行业务代码
const result = await registerUser({
name: '小明',
email: 'xiaoming@example.com'
});
// 断言语义
expect(result.id).toBe(1);
// 确保saveUser被调用过,并且参数正确
expect(saveUser).toHaveBeenCalledWith({
name: '小明',
email: 'xiaoming@example.com'
});
// 确保sendEmail被调用了
expect(sendEmail).toHaveBeenCalledWith('xiaoming@example.com');
});
再展示一个需要手动控制时序的场景:模拟一个“消息队列”的消费过程,外部依赖在收到回调之后才返回数据。
// 技术栈:JavaScript + Jest
jest.mock('./messageQueue');
const messageQueue = require('./messageQueue');
const { startConsumer } = require('./consumer');
// 模拟消息队列的pop方法,它需要在收到消息后通过回调返回
let resolveFetchNext;
messageQueue.pop.mockImplementation(() => {
// 把resolve暴露出来,测试中可以主动“投喂”消息
return new Promise((resolve) => {
resolveFetchNext = resolve;
});
});
test('消费者能处理新消息', async () => {
// 启动消费者(假设它内部循环调用pop)
const consumerPromise = startConsumer();
// 投喂第一条消息
resolveFetchNext({ content: '第一条消息' });
// 稍等片刻,让事件循环跑一下,然后再投喂第二条
await Promise.resolve();
resolveFetchNext({ content: '第二条消息' });
// 验证消费者处理了消息,这里做简单的断言
// 实际项目中可以用jest.spyOn去监控console.log
expect(messageQueue.pop).toHaveBeenCalledTimes(3); // 初始一次 + 两条消息后的两次拉取
// 如果不想一直跑,可以停止消费者,这里略过
});
这个示例展示了“手动控制Promise”的典型用法:我们保留resolve,从而在测试的任意时刻去触发异步流程,这样既能模拟复杂的时序,又不会死锁。
六、技术优缺点
6.1 这种手动控制Promise的mock方式有哪些优点
- 灵活:想什么时候给结果就什么时候给,能覆盖各种时序场景。
- 稳定:不依赖真实的外部网络或数据库,测试跑得快。
- 清晰:一眼就能看出外部依赖被替换成了确定性行为。
6.2 缺点
- 代码变多:同一个依赖的mock可能在不同测试里有不同写法,需要花些心思维护。
- 容易过度设计:如果只是简单返回数据,却写出一堆手动控制,反而增加了复杂度。
- 时序陷阱:没有经验的人可能漏掉
resolve的时机,从而制造新的死锁。
所以,我们要按需选择。能用mockResolvedValue解决的事,就不要手动控制;手动控制只用在确实需要模拟“回调式异步”或“多步时序”的时候。
七、文章总结
回到咱们开头的问题:异步单元测试里模拟外部依赖,为什么会导致死锁?核心原因就是mock出来的Promise一直没有被“了结”,让await永远等下去。避坑的关键其实很简单:要么直接返回一个已经定型的Promise,要么显式保存resolve并在合适的时候手动触发。同时,遇到定时器时需要配合Jest的假定时器异步推进工具,避免“时间停止”造成的最无语的死锁。希望这篇文章能帮大家少浪费一些排查超时的时间,把更多精力用在写有意义的断言上。
Comments