小程序自動化測試
發表時(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ò)組件都必須通過 Page
、 Component
方法來(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。