ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Jest异步测试实战:从回调到Async/Await的完整解决方案

Jest异步测试实战:从回调到Async/Await的完整解决方案 1. 项目概述为什么异步测试是前端开发的“硬骨头”做前端开发尤其是现在动辄就是SPA、Node.js后端或者全栈异步操作几乎无处不在。从最基础的setTimeout、Promise到async/await再到fetch、axios这些网络请求甚至是文件读写、数据库操作异步代码构成了现代应用的血肉。但一到写测试很多开发者就头疼了一个简单的函数因为加了异步测试用例就跑不通了要么是断言跑在了回调执行之前要么是Promise没处理好直接resolved了。我自己在带团队和做项目复盘时发现“异步代码测试”是单元测试实践中最高频的卡点之一甚至比Mock更让人困惑。Jest作为当前最主流的JavaScript测试框架它对异步测试的支持其实已经非常强大和灵活了。但问题就在于它提供了太多种方式回调函数风格、Promise返回、async/await还有done参数、resolves/rejects匹配器等等。选择太多反而让人不知道哪种场景该用哪种“兵器”用错了就是各种Timeout、false positive误报通过或者false negative误报失败。这个内容就是想把我这些年踩过的坑、总结出来的最佳实践结合具体的代码场景给你彻底讲明白。无论你是刚开始写测试的新手还是想优化现有测试套件的老手都能从这里找到可复用的模式和需要警惕的陷阱。2. 异步测试的核心困境与Jest的解决方案全景在深入具体写法之前我们得先搞清楚测试异步代码到底难在哪里同步代码测试是“直线执行”测试框架调用函数函数返回值断言判断一气呵成。而异步代码是“分支执行”主线程跑完了测试框架可能就认为测试结束了但异步回调还在任务队列里等着呢结果就是断言根本没执行到测试却“通过”了——这是最危险的情况因为它给了你虚假的安全感。Jest解决这个问题的核心思路是让测试运行器知道它需要“等待”这个异步操作完成。根据你异步代码的编写风格Jest提供了不同的“等待协议”。2.1 异步代码的三种主要形态回调函数Callback这是最传统的方式常见于Node.js早期API或一些旧库。函数执行不立即返回结果而是接收一个函数作为参数在异步操作完成后调用这个函数回调并传入结果或错误。function fetchData(callback) { setTimeout(() { callback(peanut butter); }, 100); }测试的难点在于如何告诉Jest“别急着结束等这个callback被调用后再做断言”。PromiseES6引入的现代异步解决方案。函数返回一个Promise对象它代表一个未来才会完成的操作。测试需要处理这个Promise。function fetchDataPromise() { return new Promise((resolve) { setTimeout(() resolve(peanut butter), 100); }); }测试的关键是正确处理Promise的resolve和reject状态。Async/AwaitES2017引入的语法糖基于Promise让异步代码写起来像同步代码。这是目前最推荐的方式。async function fetchDataAsync() { // 假设这里是一个真实的API调用 const response await someAPICall(); return response.data; }测试时我们可以用同样的async/await语法来等待结果非常直观。2.2 Jest的四大异步测试武器针对以上形态Jest的武器库包括done回调适用于回调函数风格。Jest会一直等待直到你调用done()函数或者done被调用时报错。返回 Promise如果你的测试代码或被测函数返回一个PromiseJest会自动等待这个Promise完成。如果Promise被拒绝reject测试将失败。Async/Await最清晰、最现代的方式。直接在测试函数前加上async关键字然后用await等待异步操作。.resolves/.rejects匹配器与expect断言结合用于更声明式地测试Promise的结果。你可以用它来等待一个Promise并用链式调用做断言。知道有哪些工具还不够更重要的是知道在什么场景下该用哪个工具以及如何避免它们各自的“坑”。3. 分场景详解四种异步测试模式的实战与避坑下面我们针对每一种方式用具体的测试用例来展示正确写法并重点剖析常见的错误和注意事项。3.1 场景一测试回调函数——谨慎使用done当你的函数或者你调用的API是回调风格时必须使用done参数来通知Jest。正确示例test(fetchData 使用回调返回 peanut butter, (done) { function callback(error, data) { if (error) { done(error); // 如果出错将错误传给done测试失败 return; } try { expect(data).toBe(peanut butter); done(); // 断言通过调用done结束测试 } catch (error) { done(error); // 如果断言失败捕获异常并通过done传递 } } fetchData(callback); // 调用被测函数 });核心避坑指南done的三大铁律必须调用如果你声明了done参数Jest就会等待它被调用。如果你忘了调用done()测试将超时失败默认超时时间5秒。这是最常见的错误之一。错误传递永远不要在回调里直接抛出错误。因为错误会被抛到异步上下文中Jest可能捕获不到导致测试静默通过。必须通过done(error)来传递错误。上面例子中的try...catch就是为了捕获expect断言可能抛出的错误。单一调用done()只能被调用一次。多次调用会导致测试失败。一个典型的错误示范test(错误的回调测试示例, (done) { fetchData((error, data) { expect(data).toBe(jelly); // 假设这个断言会失败 // 这里没有调用 done()测试会挂起直到超时 // 即使断言失败错误也被抛出到异步真空不会被Jest捕获 }); }); // 测试结果超时失败Timeout而不是断言失败增加了调试难度。3.2 场景二测试返回Promise的函数——直接返回即可这是最直接的方式之一。如果你的被测函数返回Promise或者你的测试需要执行一个返回Promise的操作直接在测试中返回这个PromiseJest会处理好一切。正确示例test(fetchDataPromise 返回 peanut butter, () { // 直接返回 PromiseJest会等待它解决resolve return fetchDataPromise().then(data { expect(data).toBe(peanut butter); }); }); // 测试异步错误Promise被reject test(fetchDataPromise 在失败时 rejects, () { // 确保返回了 Promise return fetchDataPromiseThatRejects().catch(error { expect(error).toMatch(network error); }); });核心避坑指南Promise 测试的致命疏忽忘记return是最大的坑如果你创建了一个Promise链但没有returnJest会认为你的测试函数同步执行完毕立即结束测试。此时异步操作还在后台运行断言可能根本没执行或者在其后执行导致失败信息无法被捕获。// 错误没有 return test(这将静默通过或行为异常, () { fetchDataPromise().then(data { expect(data).toBe(peanut butter); // 这个断言可能不被执行或不被报告 }); });牢记只要测试中涉及Promise链并且你想让Jest等待就必须return这个链。3.3 场景三使用 Async/Await——清晰直观的现代方式async/await语法让异步测试读起来像同步测试极大地提高了可读性。这是目前最推荐的方式尤其是代码库本身也使用async/await时。正确示例test(fetchDataAsync 返回 peanut butter, async () { const data await fetchDataAsync(); // 等待异步函数完成 expect(data).toBe(peanut butter); }); test(fetchDataAsync 在失败时抛出错误, async () { // 测试异步函数抛出错误使用 rejects 匹配器 await expect(fetchDataAsyncThatThrows()).rejects.toThrow(network error); });核心避坑指南Async/Await 的优雅陷阱async关键字不可或缺测试函数本身必须是async函数才能在里面使用await。错误处理对于被测试的异步函数可能抛出的错误有两种推荐处理方式使用.rejects匹配器如上例这是最简洁、声明式的方式。在测试内部用try...catchtest(使用 try...catch 处理错误, async () { try { await fetchDataAsyncThatThrows(); // 如果上一行没抛出错误说明测试应该失败 throw new Error(Expected function to throw); } catch (error) { expect(error.message).toMatch(network error); } });并行优化虽然await是串行的但多个独立的异步测试用例之间Jest默认会并行执行以加快速度。确保你的测试用例之间没有全局状态依赖以享受并行带来的好处。3.4 场景四使用.resolves/.rejects匹配器——声明式的断言这是Jest提供的一种非常优雅的语法糖让你可以将Promise的解决resolve或拒绝reject值直接作为断言的对象。它通常与async/await或返回Promise的方式结合使用使断言语句更集中、更易读。正确示例test(使用 .resolves 匹配器, async () { // 注意这里 await 等待的是整个断言而不仅仅是 fetchDataPromise() await expect(fetchDataPromise()).resolves.toBe(peanut butter); // 等同于const data await fetchDataPromise(); expect(data).toBe(peanut butter); }); test(使用 .rejects 匹配器, async () { await expect(fetchDataPromiseThatRejects()).rejects.toMatch(network error); }); // 也可以不用 async/await而是返回断言产生的 Promise test(使用 .resolves 并返回 Promise, () { return expect(fetchDataPromise()).resolves.toBe(peanut butter); });核心避坑指南.resolves/.rejects的微妙之处await的位置当你使用async/await时await关键字应该放在expect前面而不是Promise前面。await expect(promise).resolves...这个模式需要习惯。必须等待或返回和普通Promise测试一样如果你使用了.resolves/.rejects但没有return这个断言链或者没有在async函数中await它测试将无法正确等待。expect().resolves本身返回的是一个Promise。可读性权衡对于简单的断言.resolves.toBe()非常清晰。但如果需要对解决后的值进行多个或复杂的断言有时先await出结果再用普通方式断言可能更易读// 方式A使用 .resolves (适合简单断言) await expect(complexAsyncFunc()).resolves.toHaveProperty(status, success); // 方式B先 await 再断言 (适合复杂断言) const result await complexAsyncFunc(); expect(result.status).toBe(success); expect(result.data).toHaveLength(10); expect(result.meta.page).toBe(1); // 方式B在断言很多时逻辑更集中。4. 进阶实战模拟异步依赖与控制定时器真实的项目很少只测试一个孤立的异步函数。更多时候我们的异步函数内部会依赖其他异步模块比如HTTP请求库axiosfetch、数据库客户端、文件系统等。这时单纯的异步测试技巧不够用了我们必须结合Jest另一个核心功能模拟Mocking。4.1 模拟异步模块如axios假设我们有一个函数fetchUser它使用axios从API获取用户数据。// user.js import axios from axios; export async function fetchUser(userId) { const response await axios.get(/api/users/${userId}); return response.data; }测试这个函数时我们绝对不应该发起真实的网络请求。原因有三速度慢、依赖外部服务不可靠、无法模拟边界情况如404错误、网络超时。我们需要模拟axios模块。测试用例示例// user.test.js import axios from axios; import { fetchUser } from ./user; // 1. 模拟整个 axios 模块 jest.mock(axios); test(fetchUser 返回用户数据, async () { const mockUser { id: 1, name: John Doe }; const mockResponse { data: mockUser }; // 2. 设置模拟实现当调用 axios.get 时返回一个立即解决的 Promise axios.get.mockResolvedValue(mockResponse); // 3. 执行被测函数 const user await fetchUser(1); // 4. 断言结果 expect(user).toEqual(mockUser); // 5. 断言模拟函数被以正确的参数调用 expect(axios.get).toHaveBeenCalledWith(/api/users/1); // 可选断言调用次数 expect(axios.get).toHaveBeenCalledTimes(1); }); test(fetchUser 处理 API 错误, async () { const errorMessage Network Error; // 模拟 axios.get 返回一个被拒绝的 Promise axios.get.mockRejectedValue(new Error(errorMessage)); // 使用 .rejects 匹配器来断言异步函数抛出了错误 await expect(fetchUser(1)).rejects.toThrow(errorMessage); });实操心得模拟的粒度与重置jest.mock的自动提升jest.mock(axios)写在文件顶部时它会在所有导入之前被Jest处理确保我们导入的axios已经是模拟版本。这是Jest的一个魔法特性。清理模拟状态为了避免测试用例间相互影响例如一个测试设置的mockResolvedValue影响到下一个测试最佳实践是在每个测试前清理模拟函数的调用记录和模拟实现。这可以在beforeEach中完成beforeEach(() { // 清除所有模拟函数的调用记录和模拟实现 jest.clearAllMocks(); // 或者如果想重置为最初的模拟实现无返回值 // axios.get.mockReset(); });模拟实现的选择mockResolvedValue(value)是mockImplementation(() Promise.resolve(value))的语法糖。对于更复杂的场景如根据参数返回不同值可以使用mockImplementation。4.2 测试包含定时器的异步代码setTimeout, setInterval有些异步操作基于setTimeout或setInterval例如防抖debounce、轮询polling或模拟延迟。测试这类代码的难点在于我们不想在测试中真的等待几秒甚至几分钟。Jest提供了“假定时器”Fake Timers功能来接管全局的setTimeout、setInterval、clearTimeout等函数让我们可以手动“快进”时间。实战示例测试一个简单的防抖函数// debounce.js export function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later () { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout setTimeout(later, wait); }; }// debounce.test.js import { debounce } from ./debounce; describe(debounce, () { // 1. 在每个测试用例前启用假定时器 beforeEach(() { jest.useFakeTimers(); }); // 2. 在每个测试用例后恢复真实定时器避免影响其他测试文件 afterEach(() { jest.useRealTimers(); }); test(只会在等待时间后执行一次, () { const func jest.fn(); // 创建一个模拟函数 const debouncedFunc debounce(func, 1000); // 创建防抖函数等待1秒 // 快速连续调用3次 debouncedFunc(a); debouncedFunc(b); debouncedFunc(c); // 此时定时器已设定但时间还没走 expect(func).not.toHaveBeenCalled(); // 3. 快进时间让所有待定的定时器回调立即执行 jest.runAllTimers(); // 4. 断言函数应该只被调用了一次且参数是最后一次调用的参数 expect(func).toHaveBeenCalledTimes(1); expect(func).toHaveBeenCalledWith(c); }); test(可以使用 advanceTimersByTime 进行更精细的控制, () { const func jest.fn(); const debouncedFunc debounce(func, 1000); debouncedFunc(); expect(func).not.toHaveBeenCalled(); // 快进 500ms还没到时间 jest.advanceTimersByTime(500); expect(func).not.toHaveBeenCalled(); // 再快进 500ms总共 1000ms应该触发了 jest.advanceTimersByTime(500); expect(func).toHaveBeenCalledTimes(1); }); });注意事项假定时器的使用守则配对使用务必使用jest.useFakeTimers()和jest.useRealTimers()进行设置和清理。通常放在beforeEach/afterEach中确保测试隔离。理解不同的“快进”方法jest.runAllTimers(): 执行所有待定的宏任务setTimeout,setInterval和微任务Promise。小心递归定时器一个定时器回调里又设定了新的定时器这可能导致无限循环。jest.runOnlyPendingTimers(): 只执行当前已到期的定时器回调新设定的定时器会留到下次调用。对于递归定时器更安全。jest.advanceTimersByTime(ms): 将虚拟时间向前推进指定的毫秒数并执行在这期间应该执行的所有定时器。这是最常用、最可控的方式。现代模式Jest 26 推荐使用jest.useFakeTimers(modern)或jest.useFakeTimers({ legacy: false })来启用更安全、与现代浏览器行为更一致的假定时器实现。5. 常见问题排查与调试技巧实录即使掌握了正确的方法在实际编写异步测试时你依然会遇到一些令人困惑的错误。下面是我总结的几个最常见的问题及其解决方法。5.1 错误“Timeout - Async callback was not invoked within the 5000 ms timeout”这是异步测试中最经典的错误。意思是Jest等待了5秒默认超时时间你的测试回调无论是done、Promise还是async函数都没有被调用。排查步骤检查done调用如果你用了done确保在所有代码路径成功和错误上都调用了它。特别是要在catch块或try...catch里调用done(error)。检查Promise返回如果你测试返回Promise是否忘记了在测试函数中return它没有returnJest就不会等待。检查async/await测试函数是否标记了asyncawait是否用在了正确的Promise上检查模拟Mock如果你模拟了一个函数如axios.get是否为其设置了正确的模拟实现如mockResolvedValue如果模拟函数返回undefined或没有返回Promise那么await就会永远等下去。检查死锁或无限循环代码逻辑是否有问题比如在Promise的resolve和reject分支都没有被触发。增加超时时间对于确实需要长时间运行的测试如集成测试可以在测试用例或描述块级别单独设置超时test(slow test, async () { // ... 测试逻辑 }, 10000); // 设置10秒超时5.2 错误测试通过但控制台有“未处理的Promise拒绝”警告在测试中如果一个Promise被拒绝reject但没有被.catch()处理或await捕获Node.js会发出“UnhandledPromiseRejectionWarning”。在测试中这通常意味着你的测试没有正确地断言异步错误。解决方案确保所有被拒绝的Promise都在测试中被断言。使用await expect(...).rejects...或return promise.catch(...)。 如果错误是预期之外的并且你想让测试因此失败可以全局监听未处理的拒绝// 在测试文件顶部或 setupFilesAfterEnv 中 process.on(unhandledRejection, (reason, promise) { console.error(Unhandled Rejection at:, promise, reason:, reason); // 下面这行会让测试失败慎用最好还是显式断言所有错误 // throw reason; });5.3 模拟函数没有被调用检查调用时机和清理有时你确信代码会调用某个模拟函数但断言toHaveBeenCalled却失败了。排查步骤时机问题与假定时器相关如果调用被包裹在setTimeout或由异步操作触发你是否在断言前“推进”了时间jest.runAllTimers()或等待了异步操作完成await模拟未生效确认jest.mock()正确执行并且模拟发生在导入被测模块之前。Jest的自动提升会处理文件顶部的jest.mock但如果是动态模拟或在beforeEach中模拟要确保顺序正确。状态污染模拟函数的调用记录mock.calls是否被之前的测试污染了在beforeEach中使用jest.clearAllMocks()来清理。参数不匹配toHaveBeenCalledWith对参数进行严格比较。如果参数是对象即使内容一样但不是同一个引用也会失败。可以考虑使用expect.anything()、expect.stringContaining()等不对称匹配器Asymmetric Matchers来使断言更灵活expect(mockFunc).toHaveBeenCalledWith( expect.anything(), expect.stringContaining(api), expect.objectContaining({ id: 1 }) );5.4 使用console.log进行调试当测试行为不符合预期时最朴素的console.log依然非常有效。但要注意在异步流程中打印的时机。test(debugging async flow, async () { console.log(Test started); const promise someAsyncFunction(); console.log(Promise created, state:, promise); const result await promise; console.log(Promise resolved with:, result); // ... assertions });结合假定时器时可以打印时间jest.useFakeTimers(); console.log(Current fake time:, Date.now()); // 可能是 0 jest.advanceTimersByTime(1000); console.log(Time after advance:, Date.now()); // 应该是 10005.5 表格速查异步测试问题与解决方案问题现象可能原因解决方案测试超时失败1. 忘记调用done()。2. 忘记returnPromise。3. 模拟函数未返回 Promise导致await挂起。4. 代码逻辑死锁。1. 确保所有路径调用done。2. 检查测试函数是否返回了 Promise 链。3. 检查 Mock 实现如mockResolvedValue。4. 检查代码逻辑用console.log调试。测试意外通过误报1. 在异步回调中抛出错误但未通过done(error)传递。2. 未等待异步操作就结束了测试。1. 在回调中使用try...catch并用done(error)传递错误。2. 确保使用了return、await或done。“未处理的Promise拒绝”警告测试中产生的 Promise 被拒绝但未被捕获和断言。使用await expect(...).rejects...或return promise.catch(...)来显式处理并断言错误。模拟函数调用断言失败1. 调用发生在定时器或异步回调中断言执行过早。2. 模拟未正确设置或已被清理。3. 参数匹配不精确。1. 在断言前jest.runAllTimers()或await相关操作。2. 检查jest.mock位置和clearAllMocks的调用时机。3. 使用不对称匹配器放宽断言条件。多个测试文件间定时器干扰一个文件使用了假定时器未清理影响了其他文件。始终在afterEach中使用jest.useRealTimers()恢复。6. 架构建议为项目制定异步测试规范在个人项目或团队中统一测试风格能极大提升代码的可维护性和可读性。基于以上经验我推荐以下规范首选async/await对于所有新的异步测试统一使用async/await语法。它最简洁最符合现代JavaScript的发展方向可读性最高。统一错误断言方式对于需要断言错误的异步测试优先使用.rejects匹配器。// 推荐 await expect(asyncFunction()).rejects.toThrow(Error message); // 而非 try { await asyncFunction(); throw new Error(Expected to throw); } catch (e) { expect(e.message).toBe(Error message); }谨慎使用done仅在测试遗留的回调风格代码时使用done并严格遵守错误传递的try...catch模式。彻底模拟外部依赖对所有网络请求、数据库调用、文件IO等外部依赖进行模拟。使用jest.mock自动模拟整个模块。假定时器的清理只要用了jest.useFakeTimers()就必须在afterEach中配套使用jest.useRealTimers()。为慢测试设置独立超时对于不可避免的、真实的集成或E2E测试使用测试用例级别的超时参数而不是提高全局超时时间。最后异步测试的核心思想是“同步地处理异步”。Jest提供的所有工具都是为了让你能以一种线性、可控的思维来编写和运行测试。多写多踩坑多复盘这些模式就会变成你的肌肉记忆。当你看到一个异步函数能立刻在脑海里勾勒出它的测试用例结构时你就真正掌握了这门手艺。
返回列表