背景
前端業務場景中每次功能釋出都會面臨著相應的ui功能測試,因為前端業務的功能迭代之間往往存在顯性或者隱性的關聯性,每次上線某個功能迭代後,嚴格意義上也需要對整體功能進行迴歸,因此單靠人力的手工測試需要花費較多的時間和精力在功能迴歸上,且容易漏掉一些細節問題。
基於業務中的上述現狀,我們嘗試引入ui自動化測試來解決測試中的“重複迴歸”問題,基於 puppeteer 和 jest 兩大開源工具,封裝了一款UI自動化測試sdk,適用於以下兩個常見業務場景:
- 穩定的老業務,功能不經常迭代,透過自動化測試完成每次釋出的測試
- 正在快速迭代中業務中的核心流程,透過自動化測試保證每次釋出後核心流程的功能正常
功能說明
- 透過sdk和配置檔案,自動完成ui自動化測試流程
- 支援瀏覽器實時復現整個測試過程
- 支援pc和h5不同終端的測試
- 自動生成測試流程中的功能頁面截圖,使用者自行透過截圖進行測試結果判定
- 支援預設頁面功能的比對圖片,自動完成頁面截圖和預設比對圖片的比對,根據比對差異進行測試結果判定
- 自動輸出最終的測試報告
- 支援全域性介面的監控,支援自定義介面測試
- 同時支援es和cjs的輸出,相容import和require的匯入
- 測試過程中命令列終端中實時輸出測試進展
使用方式
第一步:前端工程中引入
透過npm的方式,安裝好sdk
npm i xxx
// 這裡需要注意的是,一定不要用原始的npm映象源,因為包裡面的chromium的源地址在國外,下載會失敗,可以用cnpm、taobao映象源等代理映象源
第二步:在工程中建立測試入口檔案,推薦的檔案目錄如下
工程根目錄
|
+---uiTest
|
+---origin // 如果需要圖片比對,存放原始的比對圖片的目錄
| xxx.png
| xxx.png
|
+---result // 存放測試過程中截圖和最終圖片比對結果圖的目錄
| xxx.png
| xxx.png
|
+---test.js // 測試入口檔案
第三步:在測試入口檔案 test.js 中接入sdk
const UITestPlayer = require('xxx');
const myUITestPlayer = new UITestPlayer({
headless: false,
fullScreen: true
});
myUITestPlayer.run(runConfig);
若使用 import 方式,則需要保證當前工程的 package.json 中具有 type:module 欄位,或者建立的入口檔案的字尾是.mjs(test.mjs)
import UITestPlayer from 'xxx';
const myUITestPlayer = new UITestPlayer({
headless: false,
});
myUITestPlayer.run(runConfig);
第四步:執行測試檔案
在當前工程下的命令列中執行下述指令,即可等待自動化測試執行
node uiTest/test.js
更好的做法是在當前工程的 package.json 中的 scripts 欄位中配置如下命令:
{
"scripts": {
"uiTest": "node uiTest/test.js"
}
}
配置完之後,在當前工程下的命令列中執行下述指令即可
npm run uiTest
配置說明
初始化options配置說明
{
chromiumPath?: string; // chromiumPath瀏覽器檔案的存放目錄, 如果需要使用本地的chromium
headless?: boolean; // 是否是無瀏覽器模式,預設true
ignoreHTTPSErrors?: boolean; //是否忽略https的報錯,預設true
fullScreen?: boolean; // 當headless為false時,開啟的瀏覽器是否全屏,優先順序高於width和height配置
width?: number; // 當headless為false時,開啟的瀏覽器的width, 預設800
height?: number; // 當headless為false時,開啟的瀏覽器的height, 預設600
}
runConfig配置檔案說明
{
title?: 'ui自動化測試' // 生成測試報告的標題
url: 'http://localhost:7002/', // 測試的頁面地址
screenshotPath?: 'uiTest/result', // 截圖存放的路徑,預設會建立uiTest目錄
expectedMismatch?: 1000; // 截圖對比可接受的畫素差,預設1000
pageLoadTest?: { // 首頁測試
value?: 'xxxx', // 比對圖片的url, 如果配置了會做截圖比對,如果為空只會截圖
trigger?: { // 截圖觸發時機,預設 time 2000ms
mode: 'time', // 截圖觸發的方式,目前有'time'和'dom'兩種方式
value: 5000 // 數字對應time,字串對應dom
}
},
process: [{ // 測試流程配置
title?: 'top檢視功能', // 測試功能名稱
step: [ // 測試的具體步驟,如果只需要一次操作則配置單個物件即可,否則按順序配置多個物件
{
eventType: 'click', // click: 點選 | hover: 滑鼠懸浮 | goTo: 頁面跳轉 | reload: 重新整理頁面 | focus: 聚焦頁面元素 | keydown: 鍵盤按鍵按下 | keyup: 鍵盤按鍵抬起 等等瀏覽器事件
eventTarget: '.minimap-container',
eventOption?: {}; // 事件引數
test?: {
value?: 'xxxx', // 比對圖片url, 配置了會做截圖比對,為空只會截圖
trigger?: { // 截圖觸發時機,預設 time 2000ms
mode: 'time', // 觸發的方式,目前有'time'和'dom'兩種方式
value: 5000 // 數字對應time,字串對應dom
},
skipScreenshot?: false; // 是否跳過截圖,預設false
},
}
],
}]
}
注意點
初始化options
- 預設不開啟瀏覽器執行效果,在控制檯會實時輸出測試過程中的關鍵資訊,可以透過 headless: true 進行開啟
- width和height是執行瀏覽器的尺寸,因此過程中的測試截圖也是這個尺寸
runConfig
- 最終會呼叫jest自動生成測試報告,title的配置就是用於最終的測試報告
- 預設的比對圖片的寬高必須和初始化 options 中的寬高尺寸(預設800*600) 保持同比例,否則縮放後會有壓縮或拉伸,會影響比對結果
- expectedMismatch 是測試截圖和預設比對截圖的對比的畫素差,完全一致的情況下最初對比的結果是0,可以根據實際要求的精確度進行數值調整
- process用於配置整體需要測試的流程,測試執行時會按照陣列物件的順序執行,裡面的每個物件都是一個單獨的測試用例。每個測試用例裡用step欄位配置具體的測試操作,大多數情況下可以採用單個操作來配置,比如點選某個按鈕,等待響應後,自動進行截圖,構成了一個step。但是如果是有多個連貫操作構成的測試操作,比如先跳轉到某個頁面,再進行點選,則在step中就要進行跳轉和點選兩個先後操作的配置。
- 測試結果判斷,如果沒有配置比對圖片,則預設每個測試用例都是透過的。如果配置了比對圖片,則只有測試截圖和比對圖片的差異小於設定的 expectedMismatch 值,才判斷測試透過。如果一個測試用例的step包含多個物件的圖片比對,則需要滿足所有圖片比對符合要求,才判斷測試透過。
- skipScreenshot一般用於step中連續操作中的不重要步驟的跳過截圖操作,比如先跳轉到某個頁面,再進行點選,跳轉到某個頁面後到截圖不是關注的重點,點選的效果才是重點。這時候就可以在這個跳轉的操作物件中配置跳過截圖。
方案設計
瞭解了功能和用法之後,下面具體說說功能中的一些具體設計思路和實現方案
設計思路
ui自動化測試的是否真的需要,跟具體的前端業務有十分密切的關聯,這一點在文章開頭就說了。根據自己的開發經驗來看,如果太過繁瑣的測試操作(比如全部手寫測試用例),往往會有點雞肋的感覺,所以sdk的設計思路就是做出一款比較簡單靈活的測試工具。測試的結果判斷可以是全自動化的(配置了比對圖片,透過圖片比對來給出測試結果),也可以是半自動化的(不配置比對圖片,只讓sdk做預設的測試截圖,最後人工查閱這些截圖做出測試結果的判斷)。實際使用下來之後,一個比較好的實踐是第一次測試半自動化,自動生成測試截圖,將符合預期的圖片作為後續測試的比對圖片,配置好比對圖片後,後續在沒有ui調整的情況下都進行自動化測試。
核心能力的選型
前端的ui自動化測試需要用到瀏覽器的能力,前期的技術調研主要考慮的就是 selenium 和 puppeteer,對比後的大致結論是 selenium 的能力更強,puppeteer的使用更友好。基於輕量化的定位,最終選取了puppeteer作為核心框架。如果要做更強的測試能力(如支援多終端,測試瀏覽器相容性)那麼可能 selenium會更適合。此外,在這個過程中,還考慮過另外一種ui自動化測試的形式,即錄屏記錄測試人對頁面的操作流程,自動化生成測試的指令碼。這種方案其實感覺更好,例如sahi pro就一定程度上支援,但是自己走下來遇到的問題比較多,偏離了"輕量化"的定位。
至於測試指令碼的的技術選型,這個因為jest和mocha都比較成熟,之前用得都比較熟悉。所以兩者其實都是可以的,最終隨機選擇了jest。
puppeteer
在這裡不說得太多,可以去 puppeteer中文官網 以及很多資料上具體看。關鍵就在於puppeteer提供了一套頁面操作相關的api,能夠喚起一個chrome瀏覽器,並且模擬出常見的使用者操作,比如滑鼠事件、鍵盤事件、頁面跳轉等,這就讓我們可以透過程式碼模擬出使用者對頁面的常見操作,構成了整個自動化測試的主體流程。此外利用puppeteer提供的請求攔截的能力,sdk封裝後就能做到對頁面介面的相關測試。
整體邏輯
初始化
sdk中封裝了一個類,在這個類接受初始化引數並進行引數處理,然後就會初始化一個 puppeteer 的例項,啟動一個全域性瀏覽器,並根據引數設定啟動的瀏覽器的尺寸、是否是h5的頁面。
private async _init() {
this.log('process', '程式初始化中...');
const browserOptions = this.browserOptionsCheck(this.option);
this.browser = await Puppeteer.launch(browserOptions);
this.page = await this.browser.newPage();
await this.runOnH5(this.option?.h5, IPHONE6);
this.log('process', '程式初始化結束');
}
執行測試操作
初始化完成後,當run方法被呼叫,就會進入測試操作,首先也會對傳入的 runConfig 進行一系列的引數處理和合並。緊接著就開始執行首頁測試的邏輯,操作瀏覽器跳轉到首頁,進行首頁的截圖操作,如果當前工程中不存在存放截圖的目錄,在這裡也會將目錄建立好。
private async _pageLoadTest() {
if (this.isClose()) return;
this.log('process', '開始執行首頁測試...');
const { value, trigger } = this.playConfig.pageLoadTest;
await this.pageGoto(this.playConfig.url);
await this.waitForByTrigger(trigger);
this.mkdirSync(this.playConfig.screenshotPath);
this.log('process', '首頁截圖中...');
const imgPath = `${this.playConfig.screenshotPath}/screenshot_home_page.png`;
try {
await this.page.screenshot({
path: imgPath,
});
this.log('success', `${imgPath} 截圖成功!`);
} catch (error) {
this.log('error', `${imgPath} 截圖失敗`);
}
this.log('process', '首頁測試結束');
}
接下來就會進行process中配置的測試操作,其實這個測試的邏輯跟pageLoadTest的首頁測試基本上相同。為什麼會分成兩個呢,是因為考慮到剛進入首頁是一個比較特殊的節點,希望把這個頁面進行截圖儲存下來,不管使用者有沒有配置pageLoadTest都會做這麼一個操作。而process則是完全交給了使用者去配置,根據配置的結果進行相應的的測試操作。process的測試程式碼跟pageLoadTest相比多了一些遍歷的操作和根據引數不同呼叫不同的頁面操作api。
const process = this.playConfig.process;
for (let i = 0; i < process.length; i++) {
const processItem = process[i];
for (let j = 0; j < processItem.step.length; j++) {
const stepItem = processItem.step[j];
const { eventType, eventTarget, eventOption, test } = stepItem;
switch (eventType) {
case processEventType.selectorclick: {
await this.page.click(eventTarget, eventOption);
break;
}
case processEventType.hover: {
await this.page.hover(eventTarget);
break;
}
case processEventType.goto: {
await this.page.goto(eventTarget, eventOption);
break;
}
......
const { trigger, skipScreenshot } = test;
await this.waitForByTrigger(trigger);
const imgPath = `${this.playConfig.screenshotPath}/screenshot_${processItem.name}_${j}.png`;
if (!skipScreenshot) {
try {
await this.page.screenshot({
path: imgPath,
});
this.log('success', `${imgPath} 截圖成功!`);
} catch (error) {
this.log('error', `${imgPath} 截圖失敗`);
}
......
this.log('process', 'process執行結束');
jest進行圖片比對
等到測試流程都執行完成後,這時候已經在相應的目錄下生成了頁面截圖的圖片。接下來就進入了最後一步:呼叫jest進行圖片對比,生成最終的測試報告。這個過程其實可以分為兩個步驟:
- 啟動jest測試指令碼
- 在測試指令碼中進行圖片比對
啟動指令碼
在這一步遇到了很多坑,首先要考慮到jest匹配測試指令碼的路徑問題,因為最終這個sdk是在業務工程中去使用的,當執行自動化測試時,所在的目錄是在業務工程的根目錄下,而此時sdk是在業務工程的node_modules下。因此在jest的配置檔案中,需要把rootDir設定為當前檔案目錄,否則預設就是被執行時的目錄,即業務工程的根目錄,那麼就找不到sdk中的index.test.js這個測試指令碼了。
rootDir: path.resolve(__dirname, '.'),
這時候又引發了第二個問題,“__dirname” 是在node環境下的存在的變數,在sdk中是獲取不到值的,因此需要透過另一種方式去獲取當前檔案的目錄,即:
import * as url from 'url';
const __dirname = url.fileURLToPath(new URL('.', import.meta.url));
解決了當前路徑的問題後,接下來就要執行jest指令碼,並把圖片比對需要的配置引數傳入指令碼中。在這裡遇到了第三個問題,就是index.test.js匹配不到的問題,試了很多種方式,配合jest配置中 rootDir、testMatch 和 testPathIgnorePatterns 欄位進行了多次的測試。根據配置說明理論上覺得應該已經可以了,但是結果一直有問題。經過反覆嘗試,最終去掉了testMatch(同理testRegex)欄位,並用 --runTestsByPath 指定檔案路徑,終於訪問到了。。。這一步花費了很多時間,解決之後淚流滿面,至於為什麼前面的多次嘗試都失敗了,可能得從jest原始碼中找答案了。。。
await execa(
'npx',
['jest', '--runTestsByPath', __dirname + 'index.test.js', '--config', JSON.stringify(configFinal)],
{
stdio: 'inherit',
}
);
配置中的幾個重要引數也提一下:
- reporters欄位指定了生成報告的工具包和具體的一些配置,jest-html-reporters會根據測試結果生成一份簡單的測試報告
- globals 引數是jest提供的可以在測試指令碼中全域性訪問到的欄位配置,把runConfig的配置掛載在__DEV__欄位下,這樣在index.test.js下就能取到了
這部分具體的程式碼如下:
try {
const jestConfig = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.(js|ts|tsx)$': 'ts-jest',
},
rootDir: path.resolve(__dirname, '.'),
testPathIgnorePatterns: ['<rootDir>/node_modules/'],
reporters: [
'default',
[
'jest-html-reporters',
{
pageTitle: this.playConfig.title,
publicPath: `${reportPath}/uiTestReport`,
filename: 'UITestReport.html',
openReport: true,
},
],
],
testTimeout: 1000 * 60 * 10,
};
const configFinal = {
globals: {
__DEV__: { ...this.playConfig },
},
...jestConfig,
};
this.log('process', '開始截圖比對');
this.closeBrowser();
await execa(
'npx',
['jest', '--runTestsByPath', __dirname + 'index.test.js', '--config', JSON.stringify(configFinal)],
{
stdio: 'inherit',
}
);
} catch (error) {
this.closeBrowser();
throw new Error('' + error);
}
this.log('done', '全部測試執行結束');
}
圖片比對和斷言
接下來就進入index.test.js中執行具體的圖片比對和測試斷言,這部分的邏輯也比較清晰,首頁pageLoadTest比對和過程process配置比對,如果沒有提供比對圖片的,則只要截圖存在就透過測試用例;如果提供了比對圖片的,呼叫圖片畫素比對方法,將測試截圖和比對圖片進行畫素比對,滿足要求則透過測試用例,不滿足則不透過,同時生成比對後的結果圖片。至此整個測試流程結束,等待生成測試報告。以首頁比對為例,程式碼如下:
describe(playConfig.globals.__DEV__.title, () => {
const { screenshotPath, pageLoadTest, process, expectedMismatch } = playConfig.globals.__DEV__;
it('首頁測試', async () => {
const originImagePath = pageLoadTest.value;
const screenShotImagePath = screenshotPath + '/screenshot_home_page.png';
const diffImagePath = screenshotPath + '/diff_home_page.png';
if (originImagePath) {
const diffRes = await diffImage(originImagePath, screenShotImagePath, diffImagePath, expectedMismatch);
try {
expect(diffRes).toBeTruthy();
} catch (error) {
throw new Error('首頁截圖對比超過預期畫素差');
}
} else {
fs.readFile(screenShotImagePath, (err, data) => {
try {
expect(!!data).toBeTruthy();
} catch (error) {
throw new Error('讀取首頁截圖失敗');
}
});
}
});
測試資訊輸出
如果啟動sdk的時候用的是無瀏覽器模式,那麼測試者是沒有直觀感受測試過程的,測試過程中花費的時間也不算短,直到整個自動化測試流程跑完之後才會自動跳出測試報告的頁面。這對測試者來說是很突兀的,甚至中途的等待過程中可能就以為出錯了。因此必要的測試過程中的資訊反饋是很重要的,如果仔細觀察上述的一些程式碼,會發現有this.log的輸出,如:
this.log('process', '程式初始化中...');
這是sdk中基於chalk這款工具封裝的終端資訊輸出方法,實際中的使用效果如下:
有了這些資訊的反饋,給測試者的體驗效果就會好很多了
效果展示
最後附上一個實際的測試效果demo(ps.錄製好影片demo才發現竟然不支援上傳本地影片...希望看到sf早日支援吧)