作者: 阿翔
前言
京喜(原京東拼購)專案,作為京東戰略級業務,擁有千萬級別的流量入口。為了保障線上業務的穩定執行,每月例行開展前端容災演習,主要包含小程式及 H5 版本,要求各頁面各模組在異常情況下進行適當的降級處理,不能出現空窗、樣式錯亂、不合理的錯誤提示等體驗問題。 原來的容災演習過程:小程式(通訊方式改成 Https )和 H5 通過 Whistle 對介面返回進行修改來模擬異常情況,驗證各頁面各模組的降級處理符合預期。容災演習是一項長期持續的工作,且涉及頁面功能及場景多,人工的切換場景模擬異常導致演習效率很低,因此想通過開發自動化測試工具來提升研發效率,讓容災演習工作隨時可以輕鬆開展。京喜 H5 和小程式場景差異比較大,因此自動化測試之路分 H5 和小程式兩部分進行,以 H5 作為一個開篇。
綜上所述,我們希望京喜 H5 自動化測試工具可以提供以下功能:
- 訪問目標頁面,對頁面進行截圖;
- 設定 UA(模擬不同渠道:微信、手Q、其它瀏覽器等);
- 模擬使用者點選、滑動頁面操作;
- 網路攔截、模擬異常情況(介面響應碼 500、介面返回資料異常);
- 操作快取資料(模擬有無快取的場景等)。
技術選型
提到 Web 的自動化測試,很多人熟悉的是 Selenium 2.0(Selenium WebDriver), 支援多平臺、多語言、多款瀏覽器(通過各種瀏覽器的驅動來驅動瀏覽器),提供了功能豐富的API介面。而隨著前端技術的發展,Selenium 2.0 逐漸呈現出環境安裝複雜、API 呼叫不友好、效能不高等缺點。新一代的自動化測試工具 —— Puppeteer ,相較於 Selenium WebDriver 環境安裝更簡單、效能更好、效率更高、在瀏覽器執行 Javascript 的 API 更簡單,它還提供了網路攔截等功能。
Puppeteer 是一個 Node 庫,它提供了一套高階 API ,通過 Devtools 協議控制Chromium
或Chrome
瀏覽器。Puppeteer
預設以Headless
模式執行,但是可以通過修改配置檔案執行“有頭”模式。
官方描述的功能:
- 生成頁面 PDF;
- 抓取 SPA(單頁應用)並生成預渲染內容(即“ SSR ”,伺服器端渲染);
- 自動提交表單,進行 UI 測試,鍵盤輸入等;
- 建立一個時時更新的自動化測試環境,使用 JavaScript 和最新的瀏覽器功能直接在最新版本的 Chrome 中執行測試;
- 捕獲網站的 Timeline Trace,用來幫助分析效能問題;
- 測試瀏覽器擴充套件。
Puppeteer 提供了一種啟動 Chromium 例項的方法。 當 Puppeteer 連線到一個 Chromium 例項的時候會通過 puppeteer.launch 或 puppeteer.connect 建立一個 Browser 物件,在通過 Browser 建立一個 Page 例項,導航到一個 Url ,然後儲存截圖。一個 Browser 例項可以有多個 Page 例項。 下面就是使用 Puppeteer 進行自動化的一個典型示例:
const puppeteer = require('puppeteer');
puppeteer.launch().then(async browser => {
const page = await browser.newPage();
await page.goto('https://example.com');
await page.screenshot({path: 'screenshot.png'});
await browser.close();
});
綜上所述,我們選擇基於 Puppeteer 來開發京喜首頁容災演習的自動化測試工具,通過 Puppeteer 提供的一系列 API ,實現訪問目標頁面、模擬異常場景、生成截圖的過程自動化。最後再通過人工比對截圖,判斷頁面降級處理是否符合預期、使用者體驗是否友好。
實現方案
我們將容災演習過程分為自動化流程和人工操作兩部分。
自動化流程:
- 模擬使用者訪問頁面操作;
- 攔截網路請求,修改介面返回資料,模擬異常場景(介面返回 500、異常資料等);
- 生成截圖。
人工操作:
自動化指令碼執行完畢後,人工比對各個場景的截圖,判斷是否符合預期。
方案流程圖:
開發實錄
安裝 Puppeteer ,你可能會遇到的那些事
通過 npm init 初始化專案後, 就可以安裝 Puppeteer 依賴了:
npm i puppeteer
:在安裝時自動下載最新版本 Chromium。
或者
(不能生成截圖)npm i puppeteer-core
:在安裝時不會自動下載 Chromium。
另外,在安裝過程中可能會因為下載 Chromium 導致報錯,官網建議是先通過 npm i --save puppeteer --ignore-scripts
阻止下載 Chromium, 然後再手動下載 Chromium 。
手動下載後,需要配置指定路徑,修改 index.js 檔案
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch({
// 執行 Chromium 或 Chrome 可執行檔案的路徑(相對路徑)
executablePath: './chrome-mac/Chromium.app/Contents/MacOS/Chromium',
headless: false
});
const page = await browser.newPage();
await page.goto('https://example.com');
await page.screenshot({path: 'screenshot.png'});
browser.close();
})();
快速建立測試用例
為了提高測試指令碼的可維護性、擴充套件性,我們將測試用例的資訊都配置到 JSON 檔案中,這樣編寫測試指令碼的時候,我們只需關注測試流程的實現。
測試用例 JSON 資料配置包括公用資料(global)
和私有資料
:
公用資料(global)
:各測試用例都需要用到的資料,如:模擬訪問的目標頁面地址、名字、描述、裝置型別等。
私有資料
: 各測試用例特定的資料,如測試模組資訊、API 地址、測試場景、預期結果、截圖名字等資料。
{
"global": {
"url": "https://wqs.jd.com/xxx/index.shtml",
"pageName": "index",
"pageDesc": "首頁",
"device": "iPhone 7"
},
"homePageApi": {
"id": 1,
"module": "home_page_api",
"moduleDesc": "首頁主介面",
"api": "https://wqcoss.jd.com/xxx",
"operation": "模擬響應碼 500",
"expectRules": [
"1. 顯示異常資訊、重新整理按鈕",
"2. 點選重新整理按鈕,顯示異常資訊",
"3. 恢復網路,點選重新整理按鈕,顯示正常資料"
],
"screenshot": [
{
"name": "normal",
"desc": "正常場景"
},
{
"name": "500_cache",
"desc": "有快取-返回500"
},
{
"name": "500_no_cache",
"desc": "無快取-返回500"
},
{
"name": "500_no_cache_reload",
"desc": "無快取-返回500-點選重新整理按鈕"
},
{
"name": "500_no_cache_recover",
"desc": "無快取-返回500-恢復網路"
}
]
},
…
}
編寫測試指令碼
我們以京喜首頁主介面的測試用例為例子,通過模介面返回 500 響應碼的異常場景,驗證主介面的異常處理機制是否完善、使用者體驗是否友好。
預期效果:
- 有快取情況下,顯示快取資料
- 無快取情況下顯示異常資訊、重新整理按鈕
- 點選重新整理按鈕,顯示異常資訊
- 恢復網路,點選重新整理按鈕,顯示正常資料
測試流程:
場景實現:
根據測試流程以及配置的測試用例資訊,編寫測試指令碼,實現測試用例場景:
- 訪問頁面
await page.goto(url)
- 生成截圖
await page.screenshot({
path: './screenshot/index_home_page_500.png'
})
- 攔截介面請求
async test () => {
... // 建立 Page 例項,訪問首頁
await page.setRequestInterception(true) // 設定攔截請求
page.on("request", interceptionEvent) // 監聽請求事件,當請求發起後頁面會觸發這個事件
... // 重新整理頁面,觸發請求攔截,生成測試場景截圖
}
若測試用例需要攔截不同的請求,或是模擬多種場景,則需要設定多個請求監聽事件。且一個事件執行結束後,必須要移除事件監聽,才能繼續下一個事件監聽。
新增事件監聽:page.on("request", eventFunction)
移除事件監聽:page.off("request", eventFunction)
// 設定攔截請求
await page.setRequestInterception(true)
const iconInterception1 = requestInterception(api, "body")
// 新增事件 1 監聽
page.on("request", iconInterception1)
await page.goto(url)
await page.screenshot({
path: './screenshot/1.png'
})
// 移除事件 1 監聽
page.off("request", iconInterception1)
const iconInterception2 = requestInterception(api, "body", )
// 新增事件 2 監聽
page.on("request", iconInterception2)
await page.goto(url)
await page.screenshot({
path: './screenshot/2.png'
})
// 移除事件 2 監聽
page.off("request", iconInterception2)
- 模擬異常資料場景,生成 mock 資料。
function requestInterception (api, setProps, setValue) {
let mockData
switch (setProps) {
case "status": // 修改返回狀態碼
mockData = {
status: setValue
}
break
case "contentType": // 修改返回內容型別
mockData = {
contentType: setValue
}
break
case "body": // 修改返回資料
mockData = {
contentType: getMockResponse(setValue)
}
break
default:
break
}
return async req => {
// 如果是需要攔截的 API,則通過 req.respond(mockData) 修改返回資料,否則 continue 繼續請求別的
if (req.url().includes(api)) { // 攔截 API
req.respond(mockData) // 修改返回資料
return false // 處理完了某個請求必須退出,不再執行 continue
}
req.continue()
}
模擬介面返回 500:
const interception500 = requestInterception(api, 'status', 500)
page.on("request", interception500) // 當請求發起後頁面會觸發這個事件
模擬異常資料:
const iconInterception = requestInterception(api, "body", {
"data": {
"modules": [{
"tpl": "3000",
"content": []
}]
}
})
page.on("request", iconInterception)
生成 mock 資料有兩種實現方案,可依據實際情況而定:
- [ ] 直接通過修改介面真實返回的資料生成 mock 資料,需要先獲取介面實時返回資料
- [x] 本地儲存一份完整的介面資料,通過修改本地儲存資料的方式生成 mock 資料(本文所述案例均基於此方案實現)
若選擇第一種方案,則需先攔截介面請求,通過 req.response() 獲取介面實時返回資料,根據測試場景修改實時返回資料作為 mock 資料。
由於京喜 H5 頁面介面返回是 JSONP 格式的資料,所以在模擬返回資料的時候,必須先擷取 JSONP 的 callback 資訊,與模擬資料拼接後再返回;
function requestInterception (api, setProps, setValue) {
let mockData
switch (setProps) {
case "status":
mockData = {
status: setValue
}
break
case "contentType":
mockData = {
contentType: setValue
}
break
default:
break
}
return async req => {
if (req.url().includes(api)) {
if (setProps === "body") {
const callback = getUrlParam("callback", req.url()) // 獲取 callback 資訊
const localData = getLocalMockResponse(api) // 匹配 API ,獲取本地儲存資料
mockData = {
body: getResponseMockLocalData(localData, setValue, callback, api) // 生成 mock 資料
}
}
req.respond(mockData) // 設定返回資料
return false
}
req.continue()
}
}
- 清除快取
page.evaluate(() => {
try {
localStorage.clear()
sessionStorage.clear()
} catch (e) {
console.log(e)
}
})
- 點選重新整理按鈕
await page.waitFor(".page-error__refresh-btn") // 可以傳 CSS 選擇器,也可以傳時間(單位毫秒)
await page.click(".page-error__refresh-btn")
在模擬點選重新整理按鈕之前,需等待按鈕渲染完成,再觸發按鈕點選。(防止重新整理頁面後,DOM 還未渲染完成的情況下,因找不到 DOM 導致報錯)
- 取消攔截,恢復網路
await page.setRequestInterception(false)
執行指令碼及除錯
由於第一階段的測試工具尚未平臺化,自動化測試流程先通過在終端輸入命令列,執行指令碼的方式啟動。
在專案的 package.json 檔案中,使用 scripts 欄位定義指令碼命令:
"scripts": {
"test:real": "node ./pages/index/index.js",
"test:mock": "node ./pages/index-mock/index.js"
},
執行:
在終端切入到專案根目錄路徑,輸入以下命令列,就可以啟動測試工具,執行測試指令碼。
- npm run test:real // 介面真實返回的資料測試
- npm run test:mock // 使用本地 mock 資料測試
除錯:
開啟除錯模式之前,需要先了解 Headless Chrome
。
Headless Chrome
,無頭模式,瀏覽器的無介面形態,可以在不開啟瀏覽器的前提下,在命令列中執行測試指令碼,能夠完全像真實瀏覽器一樣完成使用者所有操作,不用擔心執行測試指令碼時瀏覽器受到外界的干擾,也不需要藉助任何顯示裝置,使自動化測試更穩定。
Puppeteer
預設以無頭模式執行。
那麼要開啟除錯模式,就必須取消無頭模式,在開啟瀏覽器的場景下,進行自動化測試。因此,在命令列指令碼中增加了“取消無頭模式”和“開啟開發者工具”的引數,測試指令碼通過獲取到的引數,決定是否開啟除錯模式。
const headless = process.argv[2] !== 'head' // 獲取是否開啟無頭模式引數
const devtools = process.argv[3] === 'dev' // 獲取是否開啟開發者工具引數
const browser = await puppeteer.launch({
executablePath: browserPath,
headless,
devtools
})
在終端切入到專案根目錄路徑,輸入以下命令列,就可以開啟除錯模式,執行測試指令碼。
- npm run test:mock head // 開啟 Chromium 視窗
- npm run test:mock head dev // 開啟 Chromium 視窗 和 開發者工具視窗
-
head
引數:取消無頭模式,開啟 Chromium 視窗執行指令碼; -
head dev
引數:在開啟 Chromium 視窗執行指令碼,並開啟 Devtools 視窗,開啟除錯模式。
測試結果
人工比對截圖結果:
執行指令碼示例:
更多測試場景實現
1. 擷取從頁面頂部到指定 DOM 之間的區域(內容可能超出一屏的長圖)
Puppeteer 提供了四種截圖方式:
(1)擷取一屏內容(預設普通截圖);
(2)擷取指定 DOM;
(3)擷取全屏;
(4)指定裁剪區域,可設定 x、y、width、height。 x, y 是相對頁面左上角。但只能擷取一屏的內容,超出一屏不展示。
基於第四種方法進行改造:
- 通過原生 JavaScript 的 getBoundingClientRect() 方法獲取到指定 DOM 的 x,y 座標值;
- 通過 page.setViewport() 重置視口的高度;
- 呼叫截圖 API 生成截圖。
async function screenshotToElement (page, selector, path) {
try {
await page.waitForSelector(selector)
let clip = await page.evaluate(selector => {
const element = document.querySelector(selector)
let { x, y, width, height } = element.getBoundingClientRect()
return {
x: 0,
y: 0,
width,
height: M(y),
}
}, selector)
await page.setViewport(clip)
await page.screenshot({
path: path,
clip: clip
})
} catch (e) {
console.log(e)
}
}
-
height: y
:截到指定 DOM 的頂部,不包含該 DOM; -
height: y + height
: 截到指定 DOM 的底部,包含該 DOM; - 原生 Javascript 的 getBoundingClientRect() 方法獲取 DOM 元素定位和寬高值可能是小數,而 Puppeteer 的 setViewport() 設定視口方法不支援小數,所以需要對獲取到的 DOM 元素定位資訊取整。
2. 模擬不同渠道,如:手Q場景:
// 設定 UA
await page.setUserAgent("Mozilla/5.0 (iPhone; CPU iPhone OS 10_2_1 like Mac OS X) AppleWebKit/602.4.6 (KHTML, like Gecko) Mobile/14D27 QQ/6.7.1.416 V1_IPH_SQ_6.7.1_1_APP_A Pixel/750 Core/UIWebView NetType/4G QBWebViewType/1")
3. 滾動頁面
await page.evaluate((top) => {
window.scrollTo(0, top)
}, top)
page.evaluate(pageFunction, …args):在當前頁面例項上下文中執行 JavaScript 程式碼
4. 監聽頁面崩潰事件
// 當頁面崩潰時觸發
page.on('error', (e) => {
console.log(e)
})
結語
第一階段的 H5 自動化之路告一段落,容災演習已實現了半自動化,可通過在終端執行測試指令碼,模擬異常場景自動生成截圖,再配合人工比對截圖操作,判斷演習結果是否符合預期。目前已投入到每個月的容災演習中使用。
隨著京喜業務的迭代,頁面也將更新改版,因此測用例也需要持續維護和更新。後續將持續優化自動化工具,共享測試指令碼、在生成截圖的基礎上自動比對測試結果是否符合預期、資料入庫、將測試結果轉化成文件,自動傳送郵件等等。基於容災演習的自動化測試,還可擴充套件廣告位的監測,資料上報監自動化測試……
對於京喜首頁自動化測試之路,遠沒有結束,還有很多可以優化和擴充套件的地方,接下來分階段持續優化自動化測試工具,敬請期待!
相關連結
歡迎關注凹凸實驗室部落格:aotu.io
或者關注凹凸實驗室公眾號(AOTULabs),不定時推送文章: