1. 先聊清楚:小程序自动化测试的真实痛点在哪
做小程序的团队大概率都经历过这样的场面:临近发版,产品经理拿着一份三十多页的回归清单过来,测试同学一个人从首页点到个人中心,再从下单流程点回退款流程,一轮下来四五个小时,中间还得祈祷别点错。更难受的是,改了一个优惠券的计算逻辑,理论上只影响一个页面,但没人敢只在那个页面上点两下就放行,因为历史经验告诉他们,小程序里页面之间的数据流、缓存、登录态是互相牵连的。这就是minitest这类小程序自动化测试框架存在的现实理由:它不解决“测试有没有价值”这种哲学问题,它只解决“同样的一套点击流程,能不能让机器稳定地、可重复地替你跑一百遍”。
我最初接触minitest的动机很朴素:团队里小程序的 SKU 页面每个月要改三四次,每次改完都要人工核对十几种商品状态的展示,价格、库存、标签、按钮置灰逻辑。这些校验本身没有技术含量,但一旦漏掉一个“售罄商品仍可加购”的缺陷,线上就是事故。人工做这种事,第一周还能保持专注,第三周就开始靠肌肉记忆,第四周就开始“看着差不多就行了”。自动化测试框架的价值不是替代人,而是把人从这种高重复、低判断的环节里摘出来,让人去做“这个交互设计是否合理”这类真正需要脑子的事。
这篇文章面向三类人:一是手上有一个中小规模小程序、被回归测试拖住的测试或前端同学;二是想给团队引入 UI 自动化但被 Appium 那套重装备劝退的开发者;三是已经用过官方自动化 SDK、但觉得“能跑起来”和“能长期维护”之间还差一大截的人。下面会从设计思路、架构取舍、环境搭建、用例编写、工程化改造到线上排查,一条线讲透,代码和配置都给到能直接抄的程度。需要先说明一点:文中涉及的框架内部机制,一部分来自公开文档,一部分是我在实际使用中通过日志、调试和反复试错反推出来的合理推断,凡是推断的地方我会标出来,你落地时以自己的版本行为为准。
1.1 手工回归的成本,算一笔具体的账
很多人对自动化测试的犹豫,本质上是没算过账。我拿一个真实量级的项目举例:一个电商类小程序,核心页面大约 25 个,主流程 8 条(登录、搜索、商详、加购、下单、支付回调、订单列表、售后申请),每条主流程平均 12 个操作步骤。一轮完整回归大约 100 个操作节点,人工执行算上等待加载、截图记录、填写缺陷单,平均每个节点 20 秒,一轮就是 33 分钟左右,这还是比较顺利的情况。一天跑两轮,一周五个版本,一个月光回归就消耗 20 多小时人力。
而这 20 多小时里,真正需要人判断的环节可能不到 20%。剩下 80% 是“这个元素在不在”“点了之后跳没跳”“文案对不对”这类机械判断。自动化要吃掉的就是这 80%,而且它有个天然优势:机器不会因为连续点了三小时而走神,也不会因为“上个版本这里没问题”而跳过检查。我见过太多漏测事故,根因不是用例设计得不好,而是执行者疲劳导致的跳过。
当然也要把话说全:自动化不是零成本。前期的框架搭建、用例编写、选择器维护、不稳定用例治理,都是实打实的投入。我的经验判断是,如果一个项目的回归频率高于每月两次,或者核心流程超过 5 条,投入就是划算的;如果是半年发一次版的小工具类小程序,老老实实手工点更省事。这个判断标准很重要,别为了自动化而自动化。
1.2 通用 UI 自动化方案在小程序上水土不服的四个断点
在选 minitest 之前,我认真试过用通用方案硬套,结论是能跑但很难维护。原因有四个,理解了这四个断点,你就能明白为什么需要一个专门为小程序设计的框架。
第一个断点是渲染隔离。小程序的逻辑层和渲染层是分开的,你在逻辑层拿到的数据结构和渲染层真实呈现的视图之间隔着一层通信。这意味着传统基于原生控件树的定位方式,在小程序里拿到的往往是一个抽象的、层级很深的节点结构,而不是你在 WXML 里写的那个 class。想靠“第几层第几个子节点”去定位,页面一改结构就全废。专门的小程序自动化框架会通过开发者工具暴露的调试协议,直接拿到接近源码语义的节点信息,这是通用方案做不到的。
第二个断点是元素标识缺失。原生应用里每个控件通常有 accessibility id 或者 resource-id,网页里有 id 和 class,小程序虽然有 class,但很多团队写样式用的是编译后的哈希类名,或者干脆用一层层的 view 嵌套。这种情况下,用通用方案去定位元素,只能靠坐标点击,而坐标点击在不同分辨率、不同机型上必然崩。所以一个合格的小程序自动化框架,必须支持基于语义选择器的定位,并且最好能支持自定义属性打点。
第三个断点是运行环境绑定。小程序的运行离不开官方开发者工具或者真机环境,通用方案没法直接驱动这个环境,你得借助工具提供的自动化接口。这就导致框架本身强依赖于工具版本,工具一升级,框架可能就要跟着调。这是这类框架的天然局限,后面讲边界的时候我会专门展开。
第四个断点是原生组件穿透。地图、视频、canvas、富文本编辑器这些原生组件,在视图层级上是“浮”在普通节点之上的,点击事件的分发逻辑和普通节点不一样。通用方案在这里经常出现“看得见点不着”的情况。专用框架可以在协议层做特殊处理,但也不是万能的,这部分后面会作为避坑重点讲。
2. minitest 的整体设计思路拆解
搞清楚痛点之后,看框架的设计就会顺畅很多。minitest 的整体思路可以用一句话概括:它不重新发明轮子,而是把“驱动小程序的协议层”“组织用例的测试运行器”“产出结果的报告层”这三块拼装成一个开箱即用的组合。这个设计选择本身很有讲究,值得展开说。
2.1 三层结构:驱动层、用例层、报告层
驱动层负责和小程序运行环境通信。它的工作方式是在本地启动一个带调试端口的开发者工具实例,框架通过这个端口下发指令,比如“跳转到某个页面”“查找某个选择器的元素”“读取当前页面的数据对象”。你在用例里写的await page.$('.goods-card'),最终会被翻译成一条协议消息发给工具,工具执行完再把结果回传。理解这一点很关键,因为它解释了后面所有“为什么找不到元素”“为什么点了没反应”的问题——所有问题本质上都是“这条消息没被正确执行”。
用例层用的是业界成熟的 JavaScript 测试运行器,好处是断言库、钩子函数、超时控制、并行调度这些基础设施都是现成的,学习成本低,前端同学几乎零门槛。你写的describe、it、beforeAll这些结构,跟前端写单测的习惯完全一致。这个选择我觉得很聪明,因为如果框架自己造一套用例组织语法,团队接受度会低很多。
报告层则把执行结果整理成可读的输出,包括通过率、失败用例、耗时、截图附件等。默认的报告可能比较朴素,但因为它建立在标准测试运行器之上,社区里的报告插件基本都能复用。这一点在后面接流水线的时候会省不少事。
2.2 为什么不自己写裸驱动:几种方案的横向对比
我按自己实际用过的几种方式做了个对比,你可以对照自己的团队情况选。
| 方案 | 上手成本 | 元素定位能力 | 稳定性 | 维护成本 | 适合场景 |
|---|---|---|---|---|---|
| 手工回归 | 极低 | 人眼判断 | 高 | 人力持续投入 | 发版频率低于每月一次 |
| 通用 UI 自动化方案 | 高 | 弱,依赖坐标或深层结构 | 低 | 高 | 有原生 App 和 H5 统一测试需求 |
| 直接调用官方自动化 SDK | 中 | 强,支持语义选择器 | 中 | 中,需自己搭用例组织 | 只想验证一两条主流程 |
| minitest 这类组合框架 | 中低 | 强,支持语义选择器 | 中高 | 中低 | 有持续回归需求的业务团队 |
| 云端设备测试服务 | 低 | 强 | 中 | 低但按次付费 | 需要覆盖多机型、不想维护执行机 |
从表里能看出来,minitest 的定位很清晰:它比裸用 SDK 多了一层用例组织和报告,比通用方案更懂小程序,比云服务更可控、更适合放进自己的研发流程。它不适合的场景也很明确:如果你只是想在发版前跑三条冒烟用例,裸用 SDK 写个脚本更快;如果你需要覆盖几十款真机,那得靠设备云。
2.3 这套架构的代价:必须接受的三个边界
用任何工具都得先接受它的边界,否则后面会不断踩坑然后骂娘。minitest 有三个边界我建议你在团队内提前说清楚。
第一,它跑在模拟环境里,不跑真机。这意味着它能验证业务逻辑、页面跳转、数据渲染,但验证不了真机上的性能表现、特定机型的兼容性问题、系统权限弹窗。所以它不能替代真机冒烟,只能替代模拟器阶段的功能回归。我在项目里定的规则是:自动化覆盖功能正确性,真机抽检覆盖体验和兼容性,两条腿走路。
第二,它强依赖开发者工具版本。工具升级、调试协议调整,都可能导致框架行为变化。我的做法是在项目里把工具版本和框架版本一起锁进依赖清单,升级时当成一次独立的技术任务来做,而不是随手跟着编辑器更新。
第三,它对原生组件和跨端差异的支持有限。涉及地图、视频、复杂动画的页面,自动化覆盖的性价比很低,往往写十行代码只能断言一个“元素存在”。这类页面我的建议是直接排除在自动化范围外,别硬啃。
3. 环境准备:把工具链对齐到能跑起来
环境的坑比代码的坑多,这是所有做 UI 自动化的共识。我把这一节写细一点,因为我在三个不同机器上搭过这套环境,每次都栽在不同的地方。
3.1 前置依赖清单与版本对齐
需要准备的东西不多,但版本要对得上。
- Node.js:建议用 LTS 版本,太新的版本偶尔会和工具链里的原生模块冲突。我用的是 16.x 和 18.x,都验证过。
- 小程序开发者工具:必须安装,而且要登录。这一点很多人会忽略,工具没登录账号时,自动化接口是调不起来的,报错信息还特别含糊。
- 框架本身及其驱动依赖:装到项目里,别全局装,方便版本锁定。
- 测试运行器:如果用框架自带的模板工程,一般已经配好了;如果是手工搭,记得把断言库和运行器一起装。
版本对齐的经验:开发者工具的版本号、驱动依赖的版本号、框架的版本号三者之间有关联关系,官方文档里通常会给一个兼容表。我踩过的坑是工具升到某个大版本后,驱动还在旧版,结果启动时一直卡在“等待端口响应”,日志里什么有用信息都没有。解决办法很简单:先把驱动和框架都升到最新,再逐级回退定位,别同时改三个变量。
注意:不要把开发者工具装在带中文路径或者带空格的目录下。这个问题在 Windows 上尤其常见,表现是工具能正常打开,但自动化接口连不上,排查起来非常浪费时间。
3.2 服务端口与项目配置:最容易卡住的一步
框架要和工具通信,靠的是工具暴露出来的一个本地服务端口。这个端口默认是关闭的,需要手动打开。位置在工具的设置里,安全相关的那一栏,把服务端口开关打开,记住端口号。
配置里最关键的一项是项目路径。这里有个非常高频的错误:路径要指向编译产物目录,不是源码目录。小程序项目的源码需要经过构建才能被工具加载,如果你把路径指向源码根目录,工具会报“找不到 app.json”。所以要么先构建,要么在配置里指定构建后的输出目录。我一般会写一个 npm 脚本,把“构建 + 跑测试”串成一条命令,避免手工操作顺序出错。
配置项大致长这样,具体字段名以你装的版本为准:
// minitest.config.js module.exports = { // 指向构建产物目录,不是源码目录 projectPath: './dist', // 与开发者工具里打开的服务端口保持一致 cliPort: 9420, // 单条用例超时,默认值偏小,串行流程建议放宽 testTimeout: 60000, // 每个用例结束后是否重置到首页 resetBetweenTests: true };端口号这里再多说一句:同一台机器上如果同时要跑多个实例,端口必须错开,而且要跟工具的多个实例一一对应。这个能力在后面做并行的时候会用上,提前规划好端口区间能省很多事。
3.3 工程目录约定:一开始就分好层
我见过太多自动化项目最后变成一坨,根本原因是从第一天起就没有分层。我的习惯是按下面这个结构组织,不算复杂,但足够撑起几十条用例的规模。
automation/ ├── config/ │ └── minitest.config.js # 环境与端口配置 ├── pages/ # 页面对象层,一个页面一个文件 │ ├── home.page.js │ ├── goods-detail.page.js │ └── cart.page.js ├── fixtures/ # 测试数据,与用例分离 │ ├── user.json │ └── goods.json ├── specs/ # 用例层,按业务域分组 │ ├── smoke/ │ │ └── launch.spec.js │ └── order/ │ └── place-order.spec.js ├── utils/ # 等待、重试、截图等工具函数 │ ├── wait.js │ └── screenshot.js └── reports/ # 输出目录,别提交到版本库为什么要这么分?因为自动化用例的寿命比业务代码短得多,页面一改选择器就得跟着改。如果选择器散落在几十个用例文件里,一次改版就要全项目搜索替换,那滋味很难受。把它收敛到页面对象层,改动只发生在一个文件里。这个投入在用例超过二十条之后就能明显感受到回报。
4. 第一个用例:从打开首页到断言成功
环境通了之后,先别急着写复杂用例,老老实实跑通一条“打开首页、断言某个元素存在”,把整条链路验证完,再往上堆。这一步的价值在于把环境问题和用例问题隔离开。
4.1 启动与页面跳转的正确姿势
启动的代码很简单,但顺序有讲究。我的习惯是在整个测试套件开始时只启动一次,所有用例复用同一个运行实例,最后统一关闭。频繁启停会显著拉长执行时间,而且每次启动都可能有几秒钟的不稳定期。
const automator = require('miniprogram-automator'); let miniProgram; beforeAll(async () => { miniProgram = await automator.launch({ projectPath: './dist', cliPort: 9420 }); }, 60000); afterAll(async () => { if (miniProgram) { await miniProgram.close(); } });页面跳转有两种方式,用途不同。直接跳转到某个页面路径,适合用例之间互相独立、不依赖前序状态的场景;模拟用户点击跳转,适合验证交互链路的场景。要分清这两种,因为前者快但验证不到跳转逻辑,后者慢但更接近真实用户行为。我的项目里,冒烟用例用前者,核心流程用后者。
提示:
beforeAll的超时时间一定要显式设置。默认超时往往不够工具冷启动的时间,表现是第一条用例莫名失败,后面全过,极难排查。
4.2 元素定位的几种写法与选择原则
定位是自动化用例的地基,地基不稳后面全白搭。框架支持的选择器基本覆盖了日常需求:类选择器、id 选择器、属性选择器,以及基于组件名的选择。
// 类选择器:最常见,但依赖团队不改类名 const card = await page.$('.goods-card'); // 属性选择器:稳定性最好,推荐在业务代码里专门打点 const submitBtn = await page.$('[data-test="submit-order"]'); // 组件名选择器:适合批量获取同类元素 const buttons = await page.$$('button'); // 获取元素文本做断言 const text = await card.text();我的选择优先级很明确:专用测试属性 > 语义化类名 > 组件名 + 序号 > 文本内容。最不推荐的是用文本内容定位,因为文案会改,改完用例就挂,而且改文案的人通常不知道有自动化用例存在。
这里强烈建议跟开发同学约定一套测试属性规范,比如统一用>// utils/wait.js async function waitUntil(fn, { timeout = 10000, interval = 200, message = '' } = {}) { const start = Date.now(); while (Date.now() - start < timeout) { try { const result = await fn(); if (result) return result; } catch (e) { // 元素还不存在时抛错是正常的,继续轮询 } await new Promise(r => setTimeout(r, interval)); } throw new Error(`等待超时: ${message || fn.toString()}`); } module.exports = { waitUntil };
用起来是这样:
await waitUntil( async () => page.$('[data-test="order-success"]'), { timeout: 15000, message: '订单成功页未出现' } );这个封装看着朴素,但它是整个项目稳定性的核心。我在三个项目里用的都是这一套,把随机失败率从百分之十几压到了百分之一以内。轮询间隔 200ms 是个平衡点,太密会给工具端口压力,太疏会拖慢整体执行。
4.4 断言怎么写才有意义
很多人写自动化用例,断言就一句“元素存在”,这等于没测。真正有价值的断言要覆盖三个层次:结构、数据、行为。
结构断言验证页面渲染出了该有的东西,比如商详页有价格区、有库存区、有加购按钮。数据断言验证渲染的内容是对的,比如价格文本里包含数字、库存为 0 时按钮文案变成“已售罄”。行为断言验证交互结果,比如点加购后购物车角标数字加一。
it('售罄商品应展示不可购买状态', async () => { await page.reLaunch('/pages/goods/detail?id=soldout_001'); const btn = await waitUntil(() => page.$('[data-test="buy-btn"]')); const text = await btn.text(); const disabled = await btn.attribute('disabled'); expect(text).toContain('售罄'); expect(disabled).not.toBeUndefined(); });一个经验:断言要断言“关键差异”,不要断言“所有细节”。比如商品卡片上有个“已售 1.2 万件”的文案,这个数字每天都在变,断言它等于给自己埋雷。真正稳定的断言是那些业务规则决定的东西:状态、可见性、跳转目标、数量增减关系。
5. 工程化改造:让用例活过一个版本迭代
能跑通用例只是开始,真正的分水岭是“下个版本改完之后,这套用例还能不能低成本地跑起来”。这一节讲三个我实践下来最有效的改造。
5.1 页面对象分层:把选择器关进笼子
页面对象模式是老生常谈,但在小程序自动化里尤其重要,因为小程序页面的节点结构变化比原生应用频繁得多。我的写法是把“怎么找元素”和“找到之后做什么”分开:
// pages/cart.page.js class CartPage { constructor(page) { this.page = page; } get root() { return '[data-test="cart-page"]'; } get itemList() { return '[data-test="cart-item"]'; } get totalPrice() { return '[data-test="cart-total"]'; } get checkoutBtn() { return '[data-test="cart-checkout"]'; } async waitReady() { await waitUntil(() => this.page.$(this.root), { message: '购物车页未加载' }); } async getItemCount() { const items = await this.page.$$(this.itemList); return items.length; } async getTotalText() { const el = await this.page.$(this.totalPrice); return el.text(); } async checkout() { const btn = await this.page.$(this.checkoutBtn); await btn.tap(); } } module.exports = CartPage;用例层就变成了纯业务表达,读起来像需求文档:
it('购物车合计应等于各商品小计之和', async () => { const cart = new CartPage(page); await cart.waitReady(); const total = await cart.getTotalText(); // 具体金额关系在断言里表达,页面细节被封装掉了 expect(total).toMatch(/^\d+\.\d{2}$/); });这层封装带来的最大好处是改版时的爆炸半径可控。页面上某个按钮的类名变了,你只需要改pages/cart.page.js里的一行,而不是全项目搜索。
5.2 接口与系统能力的 Mock:把不可控因素挡在外面
小程序的自动化测试里,最容易导致不稳定的因素有三个:网络请求、登录态、系统能力调用。这三样如果不做 Mock,你的用例就会变成“在真实环境里碰运气”。
框架提供的 Mock 能力,本质上是在运行环境里替换掉某个系统方法,让它在用例执行期间返回你指定的值。这个能力用来隔离后端依赖非常有效:
// 在用例开始前替换网络请求,返回固定数据 await miniProgram.mockWxMethod('request', (options) => { if (options.url.includes('/api/cart/list')) { options.success({ statusCode: 200, data: { code: 0, items: [{ id: 1, price: 99.9, count: 2 }] } }); return; } // 其他请求走真实链路 const realRequest = miniProgram._originalRequest; if (typeof realRequest === 'function') realRequest(options); }); // 用例结束后必须还原,否则会污染后续用例 await miniProgram.restoreWxMethod('request');这里有两个坑要提醒。第一,Mock 一定要成对还原,放在afterEach里最稳,漏掉一个就会导致后面的用例全部受到影响,而且报错现象千奇百怪。第二,Mock 的范围要尽量窄,只拦截你关心的那个接口路径,其他请求放行。我见过有人图省事把所有请求都返回空数据,结果登录态拿不到,整个套件全挂,排查了半天才发现是自己挖的坑。
登录态的处理也是同理。小程序的登录态存在本地缓存里,我的做法是在测试套件的初始化阶段,通过设置存储的方式注入一个长期有效的测试账号标识,避免每条用例都走一遍登录流程。这能省下大量时间。
5.3 数据驱动与用例组织:别再复制粘贴
当你要验证“十种商品状态”时,千万别写十个几乎一样的it。数据驱动在这个场景下是刚需:
const cases = [ { name: '正常商品', id: 'g_001', expectText: '加入购物车', expectDisabled: false }, { name: '售罄商品', id: 'g_002', expectText: '已售罄', expectDisabled: true }, { name: '预售商品', id: 'g_003', expectText: '立即预订', expectDisabled: false } ]; describe('商品详情页按钮状态', () => { cases.forEach(({ name, id, expectText, expectDisabled }) => { it(`${name} 的按钮状态应正确`, async () => { await page.reLaunch(`/pages/goods/detail?id=${id}`); const btn = await waitUntil(() => page.$('[data-test="buy-btn"]')); expect(await btn.text()).toContain(expectText); const disabled = await btn.attribute('disabled'); expect(Boolean(disabled)).toBe(expectDisabled); }); }); });数据放在fixtures目录里,用 JSON 存,好处是测试同学也能自己加用例,不用改代码。这个做法在多角色协作的团队里很实用,能显著提高自动化用例的增长速度。
5.4 截图、日志与报告:故障现场的保留
自动化用例失败的时候,最怕的就是“报错了但不知道为什么”。所以每个用例失败时自动截图,是必须配置的能力。截图配合当前页面的路径、当前页面的数据快照,基本能还原出问题发生时的现场。
afterEach(async () => { if (expect.getState().currentTestName && expect.getState().assertionCalls === 0) { // 用例失败,落一张截图 const name = expect.getState().currentTestName.replace(/\s+/g, '_'); await miniProgram.screenshot({ path: `./reports/fail-${name}.png` }); } });页面数据快照也很值得存,因为很多时候问题是“渲染错了”,而渲染的数据从哪来,看快照最直接。把页面数据、截图、控制台日志三样凑齐,一个失败用例的定位时间能从半小时压缩到五分钟。
6. 接入流水线:并发、分片与稳定性治理
用例写够了,接下来就是让它自动跑起来。这一节讲落地时最实际的三个问题。
6.1 执行机的选择:别指望在纯命令行环境里跑
这是一个必须提前说清楚的现实:这类框架依赖带界面的开发者工具,工具本身只有桌面版本。这意味着你没法在一个干净的纯命令行服务器上直接跑起来。
主流的做法有三种。一是挂一台常驻的桌面机器做执行机,通过流水线触发远程执行,把结果回传。这是最省心的方式,我最多的时候一台机器挂了四个执行实例。二是在桌面环境里用容器化方案包装,社区里有相关实践,但稳定性需要自己调,适合有专人维护的团队。三是只在开发机上跑,作为提交前的本地校验,配合 Git 钩子做冒烟。我推荐中小团队先用第一种,等用例规模和执行频率上来之后,再考虑容器化。
6.2 分片与并行:把二十分钟压到五分钟
用例多了之后,串行执行的时间会变得难以接受。我的项目跑到六十条用例时,串行需要二十多分钟,反馈太慢,开发同学就开始绕过它,自动化的价值就打了折扣。
并行的思路很直接:起多个工具实例,每个实例监听不同端口,把用例按文件或按标签分成若干组,分给不同实例跑。配置上需要注意端口不能冲突:
// 三个实例分别使用 9420 / 9421 / 9422 const port = 9420 + Number(process.env.SHARD_INDEX || 0); const miniProgram = await automator.launch({ projectPath: './dist', cliPort: port });流水线里配置三个并行任务,每个任务带不同的SHARD_INDEX。六十条用例三个分片,时间大约能压到八分钟左右,基本满足“一个提交等得起”的标准。
不过并行会带来新的不稳定因素:多个实例同时启动,机器的 CPU 和内存压力变大,页面加载变慢,原本 10 秒能等到的元素可能要 20 秒。所以并行之后一定要回过头调大超时阈值,这是很多人没做的一步,结果就是并行一开,失败率飙升,然后又退回串行。
6.3 不稳定用例的治理:一套可执行的规则
UI 自动化的“flaky”(不稳定)问题不可避免,关键是要有治理机制,不能让不稳定用例一直挂着。我在团队里推的规则是这样的:
| 规则 | 具体做法 | 目的 |
|---|---|---|
| 失败重试 | 每条用例最多重试两次,两次都失败才算失败 | 过滤偶发时序问题 |
| 单独复跑 | 失败用例自动单独复跑一轮 | 区分“真失败”和“并发干扰” |
| 标记隔离 | 连续两周出现两次以上随机失败的用例,打标签隔离 | 防止污染整体通过率 |
| 强制修复 | 隔离用例两周内必修,修不好就删 | 避免技术债堆积 |
| 通过率阈值 | 整体通过率低于 95% 时阻断流水线 | 保证自动化的可信度 |
这套规则的核心思想是:允许偶发失败存在,但不允许它被忽略。我在一个项目里严格执行过半年,最直接的变化是大家开始信任这个自动化结果了,以前看到红色就默认“又是那个老问题”,后来看到红色会认真去看。
7. 常见问题排查速查表
这一节是我这一年多攒下来的问题清单,按出现频率排序,遇到问题可以先在这里扫一眼。
7.1 高频报错与定位路径
启动时一直卡住,没有任何输出。九成是三个原因:工具没登录、服务端口没开、projectPath指向了源码目录而不是构建产物。排查顺序就是这三步,依次确认。如果都正常,检查一下工具是不是已经在别的实例里被占用了。
元素一直找不到,但页面上明明有。先确认页面的选择器对不对,可以在工具的调试面板里直接查一下这个节点的实际属性。其次确认是不是在 iframe 或者自定义组件内部,小程序的自定义组件有作用域隔离,跨组件查找需要从组件实例进去。最后确认是不是元素被滚动到了视口外——有些框架的查找只返回可见节点。
点击没反应。最常见的原因是目标元素被一个透明的遮罩层挡住了,比如加载中的蒙层、弹窗的蒙层。解决办法是先等蒙层消失,或者主动触发关闭。另一个原因是原生组件,比如地图、视频上层无法通过普通点击事件触达,这类位置的交互建议改用页面数据断言绕过。
用例单独跑能过,一起跑就挂。典型的用例间状态污染。检查是不是前一条用例改了本地缓存没清理,或者 Mock 没有还原。我的做法是每条用例开始前重置到首页,并在afterEach里清掉所有 Mock 和临时存储。
执行到一半进程挂掉。多半是内存问题。工具实例跑久了内存会涨,尤其是在不断跳转页面、不断截图的情况下。我的做法是每跑完二十条用例重启一次实例,通过分组执行来实现。
7.2 一份可打印的排查清单
遇到失败用例,我会按这个顺序过一遍,通常三分钟内能定位方向。
- 看截图:页面停在哪个状态,是不是根本没加载出来。
- 看页面路径:是不是跳错了页面,还是没跳过去。
- 看页面数据快照:数据是不是空的,接口返回了吗。
- 看控制台日志:有没有脚本报错,有没有权限相关提示。
- 看时间戳:失败是不是集中在某几秒,怀疑机器资源问题。
- 单独复跑:能过就是环境干扰,不能过就是真问题。
这个清单看着简单,但它能避免“一上来就改代码”的坏习惯。我见过太多人看到失败直接去改选择器,结果改完还是失败,因为问题根本不在选择器。
7.3 几条踩坑之后才明白的经验
第一,别追求覆盖率,追求关键路径的确定性。我一开始想把所有页面都覆盖上,写了两百多条用例,维护成本高得离谱,最后砍到八十条核心用例,反而跑得更稳、更有价值。自动化的价值在于“稳定的重复”,不在于“数量多”。
第二,测试属性的推广要趁早。项目初期跟开发约定好规范,几乎零成本;项目做大了再去推动,改动面太大,很难推进。这是典型的“早做一分钟,省下一个月”的事。
第三,把自动化当成产品来维护,而不是当成脚本。脚本是一次性的,产品是需要迭代的。给它配好文档、定好责任人、设好评审流程,它才能长期活着。否则就是写完之后没人管,三个月后没人敢动,最后删掉重来。
第四,接受它覆盖不了全部。兼容性、性能、真实网络下的表现,这些都得靠别的测试手段。把自动化放在它擅长的位置上,别指望它包打天下。我在项目里给它的定位很明确:功能回归的第一道闸门,闸门后面还有人工抽检和真机验证。
第五,前期投入要有合理的心理预期。从零搭起一套能用的自动化回归,正常投入是一个到两个人力周,这还不包括后续的维护。如果有人在立项时跟你说“一周搞定全流程自动化”,那基本可以判断他没真做过。
最后分享一个我自己一直在用的小技巧:把每次跑出来的失败用例单独存一份清单,按原因分类,一个月后你会看到一张非常有价值的技术债地图。它告诉你的不只是“哪些用例不稳”,更重要的是“哪些页面的设计对测试不友好”。这份清单,比任何测试报告都更能推动团队改进代码结构。