尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

小程序单元测试实战:从Jest环境搭建到组件测试踩坑全记录

小程序单元测试实战:从Jest环境搭建到组件测试踩坑全记录 如果你维护的微信小程序已经跑了一两年页面数量上到二十个以后每次发版前的手工回归大概都是这样的画面打开开发者工具照着测试清单一条条点登录态要切来切去下单链路、支付流程、个人中心来回倒腾一趟下来四十分钟起步。这还不算最头疼的——改了一个模块结果另一个模块被连锁改坏了。前端自动化测试这个概念大家都听过可真到小程序里落地单元测试网上的资料少得可怜大部分文章还停留在Web项目的Jest环境里。这篇文章把我自己在小程序里做自动化测试的完整实践整理出来重点是单元测试怎么在真实项目里跑起来用了哪些工具、踩了哪些坑、每一步的判定标准是什么。先交代背景我接手的这个项目是个电商类小程序业务逻辑不算特别复杂但耦合度很高。购物车、优惠券、订单状态这几个模块互相调用光一个商品详情页的组件就关联了库存、分享、客服三个能力。早期还能靠人肉回归兜住页面一多问题就开始冒头了。所以我给自己定了个目标先不追求覆盖率多高先把最容易出问题、最值得保护的逻辑用单元测试固定下来让每次改动后都能快速知道哪些功能被影响。这条路径走下来感受是——小程序单元测试的门槛不在工具而在你对小程序运行机制的理解程度。1. 发版前的手工回归自动化测试到底在解决什么1.1 手工回归的崩溃时刻大多数前端团队不是不想做自动化测试而是被没时间需求太急写了也维护不住这几句话劝退了。但我见过太多相似的情况项目跑了大半年之后每次发版前测试清单越来越长从最初的一页A4纸变成三页Excel点完一遍还要换手机号、清缓存、切换环境地址。最崩溃的一次是灰度期间用户反馈领券失败排查到最后发现是上一个版本改动了一个公共方法而那个方法所在的工具模块当时没有任何测试保护改代码的人压根不知道哪个页面在调用它。这个场景其实暴露了一个核心矛盾人肉回归的速度远远跟不上项目迭代的速度而手工测试的覆盖率会随着页面数量增加越来越不可信任。在改动影响范围难以评估的时候你需要的不是更细的回归文档而是一套能在几秒内告诉你核心逻辑有没有崩的自动化防线。1.2 自动化测试真正解决的是什么自动化测试不是用来给老板交差的指标它的价值主要体现在三个层面回归信心改完一个工具函数或者组件跑一遍用例就知道全局哪些调用点会受影响。这个信心对重构尤其重要我后来敢动那段混乱的购物车逻辑就是因为先把相关的单元测试补齐了。行为文档好的测试用例其实是在描述这个函数在什么输入下应该有什么输出比注释和文档可靠得多——因为注释会过时测试跑不过的时候你会被迫去更新它。快速定位CI里跑挂一个用例报错信息会精确到某一个函数、某一行断言省去了手工复现的时间。千万不要把自动化测试理解成测试工程师的事。测试保护的是整个团队的生产效率谁改代码谁受益前端尤其如此。1.3 什么情况下该引入自动化测试结合我的观察一个项目出现以下三种信号就说明应该着手补测试了同一个模块在最近两三个版本内反复回归过每次都是手动验证每次都能冒出新的漏网之鱼核心业务函数金额计算、状态流转、权限判断被多个页面或组件引用改一个影响一片项目CI流程还是空的合并代码之前没有一条自动化的检查链路。信号出现了并不代表要一次性给所有代码都补测试。我建议的策略是从核心到边缘优先覆盖纯逻辑的工具函数、涉及金额和状态的业务模块、引用面最广的公共组件。等到这些兜住了再逐步扩展。2. 测试金字塔与单元测试的位置2.1 三层测试的定位与取舍前端自动化测试通常分三层单元测试、组件测试、端到端测试E2E。很多人一上来就想搭E2E实际体验下来维护成本高、执行时间长、稳定性还差。这里先看一张对比表维度单元测试组件测试E2E测试执行速度毫秒级毫秒到秒级秒到分钟级运行稳定性高较高受环境因素影响大定位问题粒度精确到函数精确到组件状态只能定位到流程编写成本低中高维护成本低中高保护力度逻辑层交互与渲染层全链路集成从成本收益来看单元测试是整体投入产出比最高的层级。E2E测试虽然最能模拟真实用户行为但一套E2E跑下来可能要好几分钟中间任何一步依赖环境抖动都可能挂掉排查起来非常费劲。所以金字塔模型强调底层单测要厚、中层组件测试适量、顶层E2E少量兜底。2.2 单元测试的独有价值单元测试的核心粒度是函数和模块。对于小程序来说值得单测的对象包括工具函数金额格式化、时间计算、节流防抖、树形结构转换业务Model层购物车增删改查、优惠券叠加规则、订单状态机流转组件的纯数据逻辑某些computed或observers内的数据处理函数。单测的优势在于它不关心页面长什么样只关心输入→输出。这就逼着你把业务逻辑从页面代码里抽离出来这本身就是一次代码质量的提升。我重构购物车模块时最先做的就是把这部分逻辑从Page里拆到独立文件里然后给每个函数写单测——这个过程中自然发现了好几处隐藏的副作用和边界问题。2.3 一个让团队尝到甜头的策略很多人做完单测就停了觉得测了也没发现bug。这其实是好事——测试最大的成就不是你抓到多少bug而是你改了代码之后敢说这块没问题。我比较推荐的做法是把单元测试接入提交钩子或者CI流程所有用例必须全绿才能合并。这样测试就从额外负担变成了合代码的通行证大家慢慢就会形成习惯改到哪个模块就顺手把那个模块的测试用例补一下。3. 小程序单元测试为什么比Web测试麻烦3.1 小程序运行环境的特殊之处小程序没法直接在浏览器里跑这一点是理解后续所有坑的关键。小程序采用的是双线程模型逻辑层AppService跑的是JavaScript引擎渲染层WebView跑的是视图代码两层之间通过setData通信。也就是说你在开发者工具里看到的界面并不是一个真正的浏览器DOM树而是小程序自己的一套组件体系。这意味着Web端常用的Jest Vue Test Utils / React Testing Library那套方案没法直接搬到小程序里。Vue组件可以被随意挂载到真实DOM上但小程序组件必须依赖小程序基础库的运行时才行。如果你的项目用了TypeScript环境差异还会更大很多Node环境里正常工作的写法在测试环境里会冒出各种七七八八的问题。3.2 wx全局对象与Page/Component生命周期小程序开发中几乎每个页面都会用到wx.getStorageSync、wx.request、wx.showToast这类API。在普通JavaScript环境下这些API根本不存在。测试环境里如果没有提前mock掉这些API跑单测时就会报类似wx is not defined的错误。另外小程序的组件体系也不是标准的类生命周期。Page和Component构造器是全局注入的组件内部还有lifetimes、observers、data、methods这些专属概念。用Jest直接require一个包含Page({...})的文件是跑不起来的因为测试环境里压根没有Page这个全局函数。3.3 为什么不能直接照搬Web的Jest方案我在刚开始尝试的时候按照Web项目的经验装好Jest写了一个工具函数的测试跑通了接着想测试一个Componnet直接把组件文件require进来果不其然一堆报错。当时还在网上找了vue单元测试报错的解决方案发现完全不适用——小程序组件不是Vue SFC不是把所有东西放到一个对象里就能被随便渲染的。问题的根源在于小程序组件需要在小程序运行时里才能触发它内部的注册、渲染、更新逻辑。所以要让单元测试跑起来必须有一个模拟小程序运行时环境的方案。这也是miniprogram-simulate这类工具存在的意义。4. 工具选型与最小测试环境搭建4.1 Jest与miniprogram-simulate的分工很多人一听到小程序单元测试就以为是一个很庞大复杂的方案实际上去掉外壳之后核心链条就两环Jest负责测试用例的组织、断言、覆盖率统计、mock机制miniprogram-simulate负责在Node环境里模拟小程序组件的运行环境包括组件加载、渲染、触发生命周期、模拟事件。miniprogram-simulate是微信官方提供的测试工具库GitHub上可以找到它在内部模拟了组件树的挂载过程可以在Jest环境里load一个组件、render出一个组件实例然后通过组件实例来读取数据、触发事件、模拟DOM节点。它走的是jsdom路线所以Jest环境需要用jsdom的测试环境来配合。4.2 搭建最小可运行的测试环境下面是完整的最小环境搭建步骤我是基于Jest 29 miniprogram-simulate 1.x实践验证过的。首先安装依赖npm install --save-dev jest miniprogram-simulate jest-environment-jsdom如果项目本身是原生小程序这一步就够了。如果是使用TypeScript还要装ts-jest或者用babel-jest做转译。我建议原生JavaScript项目直接跑TypeScript项目后面再单独处理。然后在package.json里添加Jest配置{ jest: { testEnvironment: jsdom, testMatch: [ **/__tests__/**/*.test.js ], setupFiles: [ rootDir/jest.setup.js ] } }重点说一下为什么testEnvironment要用jsdomminiprogram-simulate需要操作DOM节点来完成组件的挂载和事件模拟默认Node环境里没有document、window这些对象所以必须切到jsdom环境。接着在项目根目录创建jest.setup.js做全局mock// jest.setup.js // 模拟小程序的全局 wx 对象 global.wx { getStorageSync: jest.fn(), setStorageSync: jest.fn(), removeStorageSync: jest.fn(), request: jest.fn(), showToast: jest.fn(), showLoading: jest.fn(), hideLoading: jest.fn(), navigateTo: jest.fn(), switchTab: jest.fn(), redirectTo: jest.fn(), // 按项目实际用到的 API 继续补充 } // 模拟小程序的全局 Page 和 Component 构造器 global.Page function (options) { return options } global.Component function (options) { return options } global.getApp jest.fn(() ({ globalData: {} }))注意这一步很重要。如果没有提前mock全局对象后面每个测试用例都会因为环境缺少依赖而失败。而且我遇到过一个case某些工具函数内部调用了wx.getStorageSync但我在setup里只mock了wx.request跑用例的时候一直白屏排查了半天才发现是漏了storage这一组API。所以mock对象建议写成项目里实际用到的那一份清单。4.3 最小测试用例跑通环境搭好之后先拿一个最简单的Component验证链路通不通。假设有一个按钮组件components/custom-button/index结构如下// components/custom-button/index.js Component({ properties: { text: { type: String, value: default } }, methods: { onTap() { this.triggerEvent(clicked, { value: this.data.text }) } } })对应的测试文件长这样// __tests__/custom-button.test.js const simulate require(miniprogram-simulate) test(custom-button 渲染文本, () { const id simulate.load(/components/custom-button/index) const comp simulate.render(id) const parent document.createElement(parent-wrapper) comp.attach(parent) const textNode comp.querySelector(.btn-text) expect(textNode.textContent).toBe(default) })simulate.load接收的是组件目录路径内部会去加载对应的index.js、index.wxml、index.wxss。simulate.render返回组件实例attach是把组件挂到一个父节点上这一步会触发组件的attached生命周期。comp.querySelector可以在组件树里按class选择器找到对应的DOM节点。跑一下npm test能全部通过说明环境已就绪。5. 小程序单元测试实战从工具函数到业务组件5.1 纯逻辑工具函数的测试工具函数是单测里最友好的一类对象不需要渲染、不需要生命周期直接输入输出验证。比如项目中有一个优惠券金额计算函数calcCouponPrice// utils/coupon.js function calcCouponPrice(totalPrice, coupon) { if (!coupon || coupon.status ! available) { return totalPrice } if (totalPrice coupon.threshold) { return totalPrice } return Math.max(0, totalPrice - coupon.discountAmount) } module.exports { calcCouponPrice }对应的测试// __tests__/coupon.test.js const { calcCouponPrice } require(../utils/coupon) describe(calcCouponPrice, () { test(无优惠券时返回原价, () { expect(calcCouponPrice(100, null)).toBe(100) }) test(未达到使用门槛时返回原价, () { const coupon { status: available, threshold: 200, discountAmount: 30 } expect(calcCouponPrice(150, coupon)).toBe(150) }) test(达到门槛时扣减优惠金额, () { const coupon { status: available, threshold: 200, discountAmount: 30 } expect(calcCouponPrice(250, coupon)).toBe(220) }) test(优惠金额大于商品总价时归零, () { const coupon { status: available, threshold: 0, discountAmount: 100 } expect(calcCouponPrice(50, coupon)).toBe(0) }) test(优惠券状态不可用时返回原价, () { const coupon { status: expired, threshold: 200, discountAmount: 30 } expect(calcCouponPrice(250, coupon)).toBe(250) }) })这类测试的价值在于把金额计算这种最容易出问题的逻辑用边界输入钉死。以后任何人改动这个函数只要跑一遍测试就会立刻知道哪些情况下金额会被算错。5.2 Component组件测试渲染、交互、数据断言组件测试比工具函数复杂在需要处理渲染结果和交互事件。这里用一个带输入框的搜索组件components/search-bar/index来演示// components/search-bar/index.js Component({ data: { keyword: }, methods: { onInput(e) { this.setData({ keyword: e.detail.value }) }, onSearch() { let keyword this.data.keyword.trim() if (!keyword) { wx.showToast({ title: 请输入关键字, icon: none }) return } this.triggerEvent(search, { keyword }) } } })对应测试// __tests__/search-bar.test.js const simulate require(miniprogram-simulate) test(输入内容并点击搜索, () { const id simulate.load(/components/search-bar/index) const comp simulate.render(id) const parent document.createElement(parent-wrapper) comp.attach(parent) // 触发输入事件 const input comp.querySelector(.search-input) input.dispatchEvent(input, { detail: { value: 手机 } }) // 断言内部数据已更新 expect(comp.data.keyword).toBe(手机) // 监听自定义事件 const searchHandler jest.fn() comp.addEventListener(search, searchHandler) // 触发搜索 const searchBtn comp.querySelector(.search-btn) searchBtn.dispatchEvent(tap) // 断言自定义事件被触发并带有正确参数 expect(searchHandler).toHaveBeenCalledTimes(1) expect(searchHandler).toHaveBeenCalledWith(expect.objectContaining({ detail: { keyword: 手机 } })) })这里有个细节dispatchEvent模拟的是小程序的事件事件类型要写input、tap这些小程序自己的事件名。addEventListener对应的是组件triggerEvent触发的自定义事件这个机制和Web端CustomEvent类似但是参数结构是detail包裹的。5.3 涉及wx API的mock处理组件里如果调用了wx.request这类API测试时一定要mock掉。比如一个获取用户信息的组件// components/user-card/index.js Component({ data: { userInfo: null, loading: false }, lifetimes: { attached() { this.fetchUserInfo() } }, methods: { fetchUserInfo() { this.setData({ loading: true }) wx.request({ url: https://api.example.com/user, success: (res) { this.setData({ userInfo: res.data, loading: false }) }, fail: () { this.setData({ loading: false }) } }) } } })测试的关键是控制wx.request的返回// __tests__/user-card.test.js const simulate require(miniprogram-simulate) test(请求成功时渲染用户信息, () { wx.request.mockImplementation(({ success }) { success({ data: { name: 张三, avatar: https://example.com/avatar.png } }) }) const id simulate.load(/components/user-card/index) const comp simulate.render(id) const parent document.createElement(parent-wrapper) comp.attach(parent) return new Promise((resolve) { setTimeout(() { expect(comp.data.loading).toBe(false) expect(comp.data.userInfo.name).toBe(张三) resolve() }, 0) }) })这里为什么用setTimeout包一层因为mockImplementation里同步回调了success正常情况下setData执行完数据就已经更新了。但我还是习惯加一个异步等待因为组件内部如果有observers或者其他异步监听同步断言很容易打不稳。关于这一点下一节展开讲。6. 踩坑实录排查链路与修复细节6.1 组件渲染空白jsdom与小程序DOM的差异第一次跑组件测试时我遇到的现象是测试用例没报错但querySelector查不到任何节点组件好像根本没渲染出来。排查链路是这样的先检查simulate.load的路径对不对确认组件目录结构没写错在render之后打印comp.dom.innerHTML发现是空的怀疑是attach时机不对——原来必须先document.createElement一个父节点再attach不能直接attach到document.bodyminiprogram-simulate对父节点有要求改成parent document.createElement(parent-wrapper)之后渲染就正常了。这个坑的根源在于小程序的组件树和jsdom的DOM树不是一一对应的模拟库需要指定的wrapper来构建自己的内部结构。所以你看到的官方示例里都会先创建一个parent-wrapper这不是随手写的是为了触发内部挂载逻辑。6.2 setData异步导致的断言时序问题另一个让我头疼的坑是this.setData调用之后紧接着的断言有时能过、有时不能过。排查下来发现miniprogram-simulate对setData的处理并不是同步完成的内部会经过一个异步更新流程。组件里如果有这类逻辑methods: { updateTitle() { this.setData({ title: new title }) } }测试里直接同步断言comp.instance.updateTitle() expect(comp.data.title).toBe(new title) // 可能失败这里的教训是不要依赖同步断言组件数据要么等一个宏任务要么等一个微任务要么利用simulate提供的一些等待机制。我后来统一封装了一个工具函数function flushRender(comp) { return new Promise((resolve) { setTimeout(() resolve(), 0) }) } // 用法 await flushRender(comp) expect(comp.data.title).toBe(new title)这个工具函数在我所有组件测试里都用上了稳定了很多。后来也看到别人的方案里有直接用jest.useFakeTimers()或者await simulate.sleep(10)的殊途同归都是为了等异步更新落地。6.3 wx API遗漏mock时的报错排查最早跑一个涉及用户登录的组件时报错信息是TypeError: Cannot read property getStorageSync of undefined。这个报错对新手来说很容易懵因为看起来像是组件内部某个对象没初始化。排查链路看报错堆栈定位到是哪一行调用的wx.getStorageSync确认jest.setup.js里是否mock了wx.getStorageSync——发现只mock了wx.request补齐之后重启测试报错消失。这个坑很容易被忽略因为很多开发者在写组件时用到的wx API都集中在某个文件里常常漏掉。我的建议是维护一份项目全局的wx API清单在jest.setup.js里统一mock掉。像我上面给的示例里就按项目实际用到的API做了枚举。如果后续新增了某个wx API的调用记得同步到setup文件里否则下个测试用例就会在奇怪的地方挂掉。还有一个细节wx.showToast这类交互反馈API在测试环境里没有实际页面可以弹所以mock只需要保证函数存在、不抛异常就行。而wx.request这类有返回值的API就要仔细设置mockImplementation或者mockReturnValue确保测试能配合组件的回调逻辑正常工作。7. 单元测试之后再往前走一步7.1 组件测试与E2E测试的衔接单元测试和组件测试能把函数逻辑和组件行为兜住但小程序里还有很多真集成场景是单测覆盖不到的比如页面跳转参数传递、不同页面之间状态同步、网络请求失败后的整链路表现。这类场景我建议等单测和组件测试稳定之后再考虑E2E。可以先从渲染层覆盖、接口mock层面往前推进逐步把测试金字塔往上层补。小程序E2E这一块现在可选择的方向也不少有基于开发者工具的自动化测试能力也有一些小程序第三方测试方案落地难度比单元测试高一个量级一般团队没有必要盲从。先把单测这层做好做厚收益是最确定的。7.2 当AI开始参与自动化测试最近行业内关于自己搭agent做自动化测试AI写测试用例的讨论越来越多我也试用过用大模型辅助生成边界用例。实际体感是AI生成用例的覆盖速度很快尤其是工具函数这类纯输入输出的场景把函数签名和约束喂给它几分钟就能出一批基础用例但涉及业务语义、状态流转、历史回归场景时AI还是需要有人给它补充上下文。所以现阶段我更倾向于把AI当用例生成的加速器而不是测试的负责人。如果你有兴趣可以自己搭一个简单的agent流程准备项目代码上下文和命名规范让AI根据代码差异在分支上自动生成对应的单元测试再由人审查合入。这能解决单测维护中最大的痛点——用例产出跟不上代码变更速度。但前提是你自己得先理解单测该怎么写、覆盖到什么程度才算合格否则AI生成的用例质量你是没法判断的。按照我前面梳理的这条路径来走先搭好miniprogram-simulate Jest环境把工具函数的单测补齐再把核心组件的渲染和交互测一遍最后根据业务反馈把容易回归的模块一个个钉死。这套流程坚持两三个迭代之后你大概率会发现当年最怕的改一个模块炸一片的场景正在离你越来越远。测试覆盖率这个数字倒是次要的真正重要的是它给了你继续重构和迭代的底气。
返回列表