小程序自動化測試 - 新聞資訊 - 雲南小程序開發|雲南軟件開發|雲南網站建設-昆明融晨信息技術有限公司

159-8711-8523

雲南網建設/小程序開發/軟件開發

知識

不(bù)管是(shì)網站,軟件還是(shì)小程序,都要(yào / yāo)直接或間接能爲(wéi / wèi)您産生價值,我們在(zài)追求其視覺表現的(de)同時(shí),更側重于(yú)功能的(de)便捷,營銷的(de)便利,運營的(de)高效,讓網站成爲(wéi / wèi)營銷工具,讓軟件能切實提升企業内部管理水平和(hé / huò)效率。優秀的(de)程序爲(wéi / wèi)後期升級提供便捷的(de)支持!

您當前位置>首頁 » 新聞資訊 » 小程序相關 >

小程序自動化測試

發表時(shí)間:2021-1-11

發布人(rén):融晨科技

浏覽次數:37

背景

近期團隊打算做一個(gè)小程序自動化測試的(de)工具,期望能夠做的(de)業務人(rén)員操作一遍小程序後,自動還原之(zhī)前的(de)操作路徑,并且捕獲操作過程中發生的(de)異常,以(yǐ)此來(lái)判斷這(zhè)次發布時(shí)候會影響小程序的(de)基礎功能。

上(shàng)述描述看似簡單,但是(shì)中間還是(shì)有些難點的(de),第一個(gè)難點就(jiù)是(shì)如何在(zài)業務人(rén)員操作小程序的(de)時(shí)候記錄操作路徑,第二個(gè)難點就(jiù)是(shì)如何将記錄的(de)操作路徑進行還原。

自動化 SDK

如何将操作路徑還原這(zhè)個(gè)問題,當然首選官方提供的(de) SDK: miniprogram-automator

小程序自動化 SDK 爲(wéi / wèi)開發者提供了(le/liǎo)一套通過外部腳本操控小程序的(de)方案,從而(ér)實現小程序自動化測試的(de)目的(de)。通過該 SDK,你可以(yǐ)做到(dào)以(yǐ)下事情:

  • 控制小程序跳轉到(dào)指定頁面
  • 獲取小程序頁面數據
  • 獲取小程序頁面元素狀态
  • 觸發小程序元素綁定事件
  • 往 AppService 注入代碼片段
  • 調用 wx 對象上(shàng)任意接口
  • ...

上(shàng)面的(de)描述都來(lái)自官方文檔,建議閱讀後面内容之(zhī)前可以(yǐ)先看看 官方文檔 ,當然如果之(zhī)前用過 puppeteer ,基本是(shì)無縫銜接。下面簡單介紹下 SDK 的(de)使用方式。

// 引入sdk
const automator = require('miniprogram-automator')

// 啓動微信開發者工具
automator.launch({
  // 微信開發者工具安裝路徑下的(de) cli 工具
  // Windows下爲(wéi / wèi)安裝路徑下的(de) cli.bat
  // MacOS下爲(wéi / wèi)安裝路徑下的(de) cli
  cliPath: 'path/to/cli',
  // 項目地(dì / de)址,即要(yào / yāo)運行的(de)小程序的(de)路徑
  projectPath: 'path/to/project',
}).then(async miniProgram => { // miniProgram 爲(wéi / wèi) IDE 啓動後的(de)實例
    // 啓動小程序裏的(de) 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()
})
複制代碼

有個(gè)地(dì / de)方需要(yào / yāo)提醒一下:使用 SDK 之(zhī)前需要(yào / yāo)開啓開發者工具的(de)服務端口,要(yào / yāo)不(bù)然會啓動失敗。

捕獲用戶行爲(wéi / wèi)

有了(le/liǎo)還原操作路徑的(de)辦法,接下來(lái)就(jiù)要(yào / yāo)解決記錄操作路徑的(de)難題了(le/liǎo)。

在(zài)小程序中,并不(bù)能像 web 中通過事件冒泡的(de)方式在(zài) window 中捕獲所有的(de)事件,好在(zài)小程序所以(yǐ)的(de)頁面和(hé / huò)組件都必須通過 PageComponent 方法來(lái)包裝,所以(yǐ)我們可以(yǐ)改寫這(zhè)兩個(gè)方法,攔截傳入的(de)方法,并判斷第一個(gè)參數是(shì)否爲(wéi / wèi) event 對象,以(yǐ)此來(lái)捕獲所有的(de)事件。

// 暫存原生方法
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 // 取出(chū)第一個(gè)參數
    // 判斷是(shì)否爲(wéi / wèi) event 對象
    if (evt && evt.target && evt.type) {
      // 記錄用戶行爲(wéi / wèi)
    }
    return method.apply(this, args)
  }
}
複制代碼

這(zhè)裏的(de)代碼隻是(shì)代理了(le/liǎo)所有的(de)事件方法,并不(bù)能用來(lái)還原用戶的(de)行爲(wéi / wèi),要(yào / yāo)還原用戶行爲(wéi / wèi)還必須知道(dào)該事件類型是(shì)否是(shì)需要(yào / yāo)的(de),比如點擊、長按、輸入。

const evtTypes = [
    'tap', // 點擊
    'input', // 輸入
    'confirm', // 回車
    'longpress' // 長按
]
const hookMethod = (name, method) => {
  return function(...args) {
    const [evt] = args // 取出(chū)第一個(gè)參數
    // 判斷是(shì)否爲(wéi / wèi) event 對象
    if (
      evt && evt.target && evt.type &&
      evtTypes.includes(evt.type) // 判斷事件類型
    ) {
      // 記錄用戶行爲(wéi / wèi)
    }
    return method.apply(this, args)
  }
}
複制代碼

确定事件類型之(zhī)後,還需要(yào / yāo)明确點擊的(de)元素到(dào)底是(shì)哪個(gè),但是(shì)小程序裏面比較坑的(de)地(dì / de)方就(jiù)是(shì),event 對象的(de) target 屬性中,并沒有元素的(de)類名,但是(shì)可以(yǐ)獲取元素的(de) dataset。

爲(wéi / wèi)了(le/liǎo)準确的(de)獲取元素,我們需要(yào / yāo)在(zài)構建中增加一個(gè)步驟,修改 wxml 文件,将所以(yǐ)元素的(de) class 屬性複制一份到(dào) 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>
複制代碼

但是(shì)獲取到(dào) class 之(zhī)後,又會有另一個(gè)坑,小程序的(de)自動化測試工具并不(bù)能直接獲取頁面裏自定義組件中的(de)元素,必須先獲取自定義組件。

<!-- 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 會得到(dào) null
const element = await page.$('.toast-close')
element.tap() // Error!

// 必須先通過自定義組件的(de) tagName 找到(dào)自定義組件
// 再從自定義組件中通過 className 查找對應元素
const element = await page.$('toast .toast-close')
element.tap()
複制代碼

所以(yǐ)我們在(zài)構建操作的(de)時(shí)候,還需要(yào / yāo)爲(wéi / wèi)元素插入 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" />
複制代碼

現在(zài)我們可以(yǐ)繼續愉快的(de)記錄用戶行爲(wéi / wèi)了(le/liǎo)。

// 記錄用戶行爲(wéi / wèi)的(de)數組
const actions = [];
// 添加用戶行爲(wéi / wèi)
const addAction = (type, query, value = http://www.wxapp-union.com/'') => {
  actions.push({
    time: Date.now(),
    type,
    query,
    value
  })
}

// 代理事件方法
const hookMethod = (name, method, isComponent) => {
  return function(...args) {
    const [evt] = args // 取出(chū)第一個(gè)參數
    // 判斷是(shì)否爲(wéi / wèi) 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 = http://www.wxapp-union.com/'' } = detail // input事件觸發時(shí),輸入框的(de)值
      // 記錄用戶行爲(wéi / wèi)
      let query = ''
      if (isComponent) {
        // 如果是(shì)組件内的(de)方法,需要(yào / yāo)獲取當前組件的(de) tagName
        query = `${this.dataset.tagName} `
      }
      if (id) {
        // id 存在(zài),則直接通過 id 查找元素
        query += id
      } else {
        // id 不(bù)存在(zài),才通過 className 查找元素
        query += className
      }
      addAction(type, query, value)
    }
    return method.apply(this, args)
  }
}
複制代碼

到(dào)這(zhè)裏已經記錄了(le/liǎo)用戶所有的(de)點擊、輸入、回車相關的(de)操作,但是(shì)還有一個(gè)滾動屏幕的(de)操作還沒記錄。這(zhè)裏可以(yǐ)直接監聽 Page 的(de) onPageScroll。

// 記錄用戶行爲(wéi / wèi)的(de)數組
const actions = [];
// 添加用戶行爲(wéi / wèi)
const addAction = (type, query, value = http://www.wxapp-union.com/'') => {
  if (type === 'scroll' || type === 'input') {
    // 如果上(shàng)一次行爲(wéi / wèi)也(yě)是(shì)滾動或輸入,則重置 value 即可
    const last = this.actions[this.actions.length - 1]
    if (last && last.type === type) {
      last.value = http://www.wxapp-union.com/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)
}
複制代碼

這(zhè)裏有個(gè)優化點,就(jiù)是(shì)滾動操作記錄的(de)時(shí)候,可以(yǐ)判斷一下上(shàng)次操作是(shì)否也(yě)爲(wéi / wèi)滾動操作,如果是(shì)同一個(gè)操作,則隻需要(yào / yāo)修改一下滾動距離即可,以(yǐ)爲(wéi / wèi)兩次滾動可以(yǐ)一步到(dào)位。同理,輸入事件也(yě)是(shì),輸入的(de)值也(yě)可以(yǐ)一步到(dào)位。

還原用戶行爲(wéi / wèi)

用戶操作完畢後,可以(yǐ)在(zài)控制台輸出(chū)用戶行爲(wéi / wèi)的(de) json 文本,把 json 文本複制出(chū)來(lái)後,就(jiù)可以(yǐ)通過自動化工具運行了(le/liǎo)。

// 引入sdk
const automator = require('miniprogram-automator')

// 用戶操作行爲(wéi / wèi)
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) {
      // 計算兩次操作之(zhī)間的(de)等待時(shí)間
        await page.waitFor(time - prevTime)
    }
    // 重置上(shàng)次操作時(shí)間
    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,防止頁面跳轉過程中,後面的(de)操作找不(bù)到(dào)頁面
    await page.waitFor(5000)
  }

    // 關閉 IDE
  await miniProgram.close()
})
複制代碼

這(zhè)裏隻是(shì)簡單的(de)還原了(le/liǎo)用戶的(de)操作行爲(wéi / wèi),實際運行過程中,還會涉及到(dào)網絡請求和(hé / huò) localstorage 的(de) mock,這(zhè)裏不(bù)再展開講述。同時(shí),我們還可以(yǐ)接入 jest 工具,更加方便用例的(de)編寫。

總結

看似很難的(de)需求,隻要(yào / yāo)用心去發掘,總能找到(dào)對應的(de)解決辦法。另外微信小程序的(de)自動化工具真的(de)有很多坑,遇到(dào)問題可以(yǐ)先到(dào)小程序社區去找找,大(dà)部分坑都有前人(rén)踩過,還有一些一時(shí)無法解決的(de)問題隻能想其他(tā)辦法來(lái)規避。最後祝願天下無 bug。

相關案例查看更多