近期團隊打算作一個小程序自動化測試的工具,指望可以作到業務人員操做一遍小程序後,自動還原以前的操做路徑,而且捕獲操做過程當中發生的異常,以此來判斷此次發佈是否會影響小程序的基礎功能。html
上述描述看似簡單,可是中間仍是有些難點的,第一個難點就是如何在業務人員操做小程序的時候記錄操做路徑,第二個難點就是如何將記錄的操做路徑進行還原。web
如何將操做路徑還原這個問題,首選官方提供的 SDK: miniprogram-automator
。npm
小程序自動化 SDK 爲開發者提供了一套經過外部腳本操控小程序的方案,從而實現小程序自動化測試的目的。經過該 SDK,你能夠作到如下事情:json
上面的描述都來自官方文檔,建議閱讀後面內容以前能夠先看看官方文檔,固然若是以前用過 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 中捕獲全部的事件,好在小程序因此的頁面和組件都必須經過 Page
、Component
方法來包裝,因此咱們能夠改寫這兩個方法,攔截傳入的方法,並判斷第一個參數是否爲 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。網絡
爲了準確的獲取元素,咱們須要在構建中增長一個步驟,修改 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。