小程序自动化测试

背景

近期团队打算作一个小程序自动化测试的工具,指望可以作到业务人员操做一遍小程序后,自动还原以前的操做路径,而且捕获操做过程当中发生的异常,以此来判断此次发布是否会影响小程序的基础功能。html

方案

上述描述看似简单,可是中间仍是有些难点的,第一个难点就是如何在业务人员操做小程序的时候记录操做路径,第二个难点就是如何将记录的操做路径进行还原。web

自动化 SDK

如何将操做路径还原这个问题,首选官方提供的 SDK: miniprogram-automatornpm

小程序自动化 SDK 为开发者提供了一套经过外部脚本操控小程序的方案,从而实现小程序自动化测试的目的。经过该 SDK,你能够作到如下事情:json

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

上面的描述都来自官方文档,建议阅读后面内容以前能够先看看官方文档,固然若是以前用过 puppeteer ,也能够快速上手,api 基本一致。下面简单介绍下 SDK 的使用方式。小程序

// 引入sdk
const 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 以前须要开启开发者工具的服务端口,要否则会启动失败。微信小程序

开启服务端口

捕获用户行为

有了还原操做路径的办法,接下来就要解决记录操做路径的难题了。api

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

// 暂存原生方法
const originPage = Page
const originComponent = Component

// 改写 Page
Page = (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)
}
// 改写 Component
Component = (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。网络

event对象

为了准确的获取元素,咱们须要在构建中增长一个步骤,修改 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 会获得 null
const 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 文本复制出来后,就能够经过自动化工具运行了。

// 引入sdk
const 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。

相关文章
相关标签/搜索