小程序自動化測試

背景

近期團隊打算作一個小程序自動化測試的工具,指望可以作到業務人員操做一遍小程序後,自動還原以前的操做路徑,而且捕獲操做過程當中發生的異常,以此來判斷此次發佈是否會影響小程序的基礎功能。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。

相關文章
相關標籤/搜索