背景

近期团队打算做一个小程序自动化测试的工具,冀望可能做到业务人员操作一遍小程序后,主动还原之前的操作门路,并且捕捉操作过程中产生的异样,以此来判断这次公布是否会影响小程序的根底性能。

上述形容看似简略,然而两头还是有些难点的,第一个难点就是如何在业务人员操作小程序的时候记录操作门路,第二个难点就是如何将记录的操作门路进行还原。

自动化 SDK

如何将操作门路还原这个问题,首选官网提供的 SDK: miniprogram-automator

小程序自动化 SDK 为开发者提供了一套通过内部脚本操控小程序的计划,从而实现小程序自动化测试的目标。通过该 SDK,你能够做到以下事件:

  • 管制小程序跳转到指定页面
  • 获取小程序页面数据
  • 获取小程序页面元素状态
  • 触发小程序元素绑定事件
  • 往 AppService 注入代码片段
  • 调用 wx 对象上任意接口
  • ...

下面的形容都来自官网文档,倡议浏览前面内容之前能够先看看官网文档,当然如果之前用过 puppeteer ,也能够疾速上手,api 基本一致。上面简略介绍下 SDK 的应用形式。

// 引入sdkconst automator = require('miniprogram-automator')// 启动微信开发者工具automator.launch({  // 微信开发者工具装置门路下的 cli 工具  // Windows下为装置门路下的 cli.bat  // MacOS下为装置门路下的 cli  cliPath: 'path/to/cli',  // 我的项目地址,即要运行的小程序的门路  projectPath: 'path/to/project',}).then(async miniProgram => { // miniProgram 为 IDE 启动后的实例    // 启动小程序里的 index 页面  const page = await miniProgram.reLaunch('/page/index/index')  // 期待 500 ms  await page.waitFor(500)  // 获取页面元素  const element = await page.$('.main-btn')  // 点击元素  await element.tap()    // 敞开 IDE  await miniProgram.close()})

有个中央须要揭示一下:应用 SDK 之前须要开启开发者工具的服务端口,要不然会启动失败。

捕捉用户行为

有了还原操作门路的方法,接下来就要解决记录操作门路的难题了。

在小程序中,并不能像 web 中通过事件冒泡的形式在 window 中捕捉所有的事件,好在小程序所以的页面和组件都必须通过 PageComponent 办法来包装,所以咱们能够改写这两个办法,拦挡传入的办法,并判断第一个参数是否为 event 对象,以此来捕捉所有的事件。

// 暂存原生办法const originPage = Pageconst originComponent = Component// 改写 PagePage = (params) => {  const names = Object.keys(params)  for (const name of names) {    // 进行办法拦挡    if (typeof obj[name] === 'function') {      params[name] = hookMethod(name, params[name], false)    }  }  originPage(params)}// 改写 ComponentComponent = (params) => {  if (params.methods) {      const { methods } = params      const names = Object.keys(methods)      for (const name of names) {        // 进行办法拦挡        if (typeof methods[name] === 'function') {          methods[name] = hookMethod(name, methods[name], true)        }      }  }  originComponent(params)}const hookMethod = (name, method, isComponent) => {  return function(...args) {    const [evt] = args // 取出第一个参数    // 判断是否为 event 对象    if (evt && evt.target && evt.type) {      // 记录用户行为    }    return method.apply(this, args)  }}

这里的代码只是代理了所有的事件办法,并不能用来还原用户的行为,要还原用户行为还必须晓得该事件类型是否是须要的,比方点击、长按、输出。

const evtTypes = [    'tap', // 点击    'input', // 输出    'confirm', // 回车    'longpress' // 长按]const hookMethod = (name, method) => {  return function(...args) {    const [evt] = args // 取出第一个参数    // 判断是否为 event 对象    if (      evt && evt.target && evt.type &&      evtTypes.includes(evt.type) // 判断事件类型    ) {      // 记录用户行为    }    return method.apply(this, args)  }}

确定事件类型之后,还须要明确点击的元素到底是哪个,然而小程序外面比拟坑的中央就是,event 对象的 target 属性中,并没有元素的类名,然而能够获取元素的 dataset。

为了精确的获取元素,咱们须要在构建中减少一个步骤,批改 wxml 文件,将所有元素的 class 属性复制一份到 data-className 中。

<!-- 构建前 --><view class="close-btn"></view><view class="{{mainClassName}}"></view><!-- 构建后 --><view class="close-btn" data-className="close-btn"></view><view class="{{mainClassName}}" data-className="{{mainClassName}}"></view>

然而获取到 class 之后,又会有另一个坑,小程序的自动化测试工具并不能间接获取页面里自定义组件中的元素,必须先获取自定义组件。

<!-- Page --><toast text="loading" show="{{showToast}}" /><!-- Component --><view class="toast" wx:if="{{show}}">  <text class="toast-text">{{text}}</text>  <view class="toast-close" /></view>
// 如果间接查找 .toast-close 会失去 nullconst element = await page.$('.toast-close')element.tap() // Error!// 必须先通过自定义组件的 tagName 找到自定义组件// 再从自定义组件中通过 className 查找对应元素const element = await page.$('toast .toast-close')element.tap()

所以咱们在构建操作的时候,还须要为元素插入 tagName。

<!-- 构建前 --><view class="close-btn" /><toast text="loading" show="{{showToast}}" /><!-- 构建后 --><view class="close-btn" data-className="close-btn" data-tagName="view" /><toast text="loading" show="{{showToast}}" data-tagName="toast" />

当初咱们能够持续欢快的记录用户行为了。

// 记录用户行为的数组const actions = [];// 增加用户行为const addAction = (type, query, value = '') => {  actions.push({    time: Date.now(),    type,    query,    value  })}// 代理事件办法const hookMethod = (name, method, isComponent) => {  return function(...args) {    const [evt] = args // 取出第一个参数    // 判断是否为 event 对象    if (      evt && evt.target && evt.type &&      evtTypes.includes(evt.type) // 判断事件类型    ) {      const { type, target, detail } = evt      const { id, dataset = {} } = target        const { className = '' } = dataset        const { value = '' } = detail // input事件触发时,输入框的值      // 记录用户行为      let query = ''      if (isComponent) {        // 如果是组件内的办法,须要获取以后组件的 tagName        query = `${this.dataset.tagName} `      }      if (id) {        // id 存在,则间接通过 id 查找元素        query += id      } else {        // id 不存在,才通过 className 查找元素        query += className      }      addAction(type, query, value)    }    return method.apply(this, args)  }}

到这里曾经记录了用户所有的点击、输出、回车相干的操作。然而还有滚动屏幕的操作没有记录,咱们能够间接代理 Page 的 onPageScroll 办法。

// 记录用户行为的数组const actions = [];// 增加用户行为const addAction = (type, query, value = '') => {  if (type === 'scroll' || type === 'input') {    // 如果上一次行为也是滚动或输出,则重置 value 即可    const last = this.actions[this.actions.length - 1]    if (last && last.type === type) {      last.value = value      last.time = Date.now()      return    }  }  actions.push({    time: Date.now(),    type,    query,    value  })}Page = (params) => {  const names = Object.keys(params)  for (const name of names) {    // 进行办法拦挡    if (typeof obj[name] === 'function') {      params[name] = hookMethod(name, params[name], false)    }  }  const { onPageScroll } = params  // 拦挡滚动事件  params.onPageScroll = function (...args) {    const [evt] = args    const { scrollTop } = evt    addAction('scroll', '', scrollTop)    onPageScroll.apply(this, args)  }  originPage(params)}

这里有个优化点,就是滚动操作记录的时候,能够判断一下上次操作是否也为滚动操作,如果是同一个操作,则只须要批改一下滚动间隔即可,因为两次滚动能够一步到位。同理,输出事件也是,输出的值也能够一步到位。

还原用户行为

用户操作结束后,能够在控制台输入用户行为的 json 文本,把 json 文本复制进去后,就能够通过自动化工具运行了。

// 引入sdkconst automator = require('miniprogram-automator')// 用户操作行为const actions = [  { type: 'tap', query: 'goods .title', value: '', time: 1596965650000 },  { type: 'scroll', query: '', value: 560, time: 1596965710680 },  { type: 'tap', query: 'gotoTop', value: '', time: 1596965770000 }]// 启动微信开发者工具automator.launch({  projectPath: 'path/to/project',}).then(async miniProgram => {  let page = await miniProgram.reLaunch('/page/index/index')    let prevTime  for (const action of actions) {    const { type, query, value, time } = action    if (prevTime) {      // 计算两次操作之间的等待时间          await page.waitFor(time - prevTime)    }    // 重置上次操作工夫    prevTime = time        // 获取以后页面实例    page = await miniProgram.currentPage()    switch (type) {      case 'tap':              const element = await page.$(query)        await element.tap()        break;      case 'input':              const element = await page.$(query)        await element.input(value)        break;      case 'confirm':              const element = await page.$(query)                 await element.trigger('confirm', { value });        break;      case 'scroll':        await miniProgram.pageScrollTo(value)        break;    }    // 每次操作完结后,期待 5s,避免页面跳转过程中,前面的操作找不到页面    await page.waitFor(5000)  }    // 敞开 IDE  await miniProgram.close()})

这里只是简略的还原了用户的操作行为,理论运行过程中,还会波及到网络申请和 localstorage 的 mock,这里不再开展讲述。同时,咱们还能够接入 jest 工具,更加不便用例的编写。

总结

看似很难的需要,只有用心去挖掘,总能找到对应的解决办法。另外微信小程序的自动化工具真的有很多坑,遇到问题能够先到小程序社区去找找,大部分坑都有前人踩过,还有一些一时无奈解决的问题只能想其余方法来躲避。最初祝福天下无 bug。