博客 / 詳情

返回

前端ui自動化測試sdk封裝

背景

前端業務場景中每次功能發佈都會面臨着相應的ui功能測試,因為前端業務的功能迭代之間往往存在顯性或者隱性的關聯性,每次上線某個功能迭代後,嚴格意義上也需要對整體功能進行迴歸,因此單靠人力的手工測試需要花費較多的時間和精力在功能迴歸上,且容易漏掉一些細節問題。
基於業務中的上述現狀,我們嘗試引入ui自動化測試來解決測試中的“重複迴歸”問題,基於 puppeteer 和 jest 兩大開源工具,封裝了一款UI自動化測試sdk,適用於以下兩個常見業務場景:

  • 穩定的老業務,功能不經常迭代,通過自動化測試完成每次發佈的測試
  • 正在快速迭代中業務中的核心流程,通過自動化測試保證每次發佈後核心流程的功能正常

功能説明

  1. 通過sdk和配置文件,自動完成ui自動化測試流程
  2. 支持瀏覽器實時復現整個測試過程
  3. 支持pc和h5不同終端的測試
  4. 自動生成測試流程中的功能頁面截圖,用户自行通過截圖進行測試結果判定
  5. 支持預設頁面功能的比對圖片,自動完成頁面截圖和預設比對圖片的比對,根據比對差異進行測試結果判定
  6. 自動輸出最終的測試報告
  7. 支持全局接口的監控,支持自定義接口測試
  8. 同時支持es和cjs的輸出,兼容import和require的導入
  9. 測試過程中命令行終端中實時輸出測試進展

使用方式

第一步:前端工程中引入

通過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
  1. 默認不開啓瀏覽器運行效果,在控制枱會實時輸出測試過程中的關鍵信息,可以通過 headless: true 進行開啓
  2. width和height是運行瀏覽器的尺寸,因此過程中的測試截圖也是這個尺寸
runConfig
  1. 最終會調用jest自動生成測試報告,title的配置就是用於最終的測試報告
  2. 預設的比對圖片的寬高必須和初始化 options 中的寬高尺寸(默認800*600) 保持同比例,否則縮放後會有壓縮或拉伸,會影響比對結果
  3. expectedMismatch 是測試截圖和預設比對截圖的對比的像素差,完全一致的情況下最初對比的結果是0,可以根據實際要求的精確度進行數值調整
  4. process用於配置整體需要測試的流程,測試運行時會按照數組對象的順序執行,裏面的每個對象都是一個單獨的測試用例。每個測試用例裏用step字段配置具體的測試操作,大多數情況下可以採用單個操作來配置,比如點擊某個按鈕,等待響應後,自動進行截圖,構成了一個step。但是如果是有多個連貫操作構成的測試操作,比如先跳轉到某個頁面,再進行點擊,則在step中就要進行跳轉和點擊兩個先後操作的配置。
  5. 測試結果判斷,如果沒有配置比對圖片,則默認每個測試用例都是通過的。如果配置了比對圖片,則只有測試截圖和比對圖片的差異小於設定的 expectedMismatch 值,才判斷測試通過。如果一個測試用例的step包含多個對象的圖片比對,則需要滿足所有圖片比對符合要求,才判斷測試通過。
  6. 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進行圖片對比,生成最終的測試報告。這個過程其實可以分為兩個步驟:

  1. 啓動jest測試腳本
  2. 在測試腳本中進行圖片比對
啓動腳本

在這一步遇到了很多坑,首先要考慮到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',
        }
      );

配置中的幾個重要參數也提一下:

  1. reporters字段指定了生成報告的工具包和具體的一些配置,jest-html-reporters會根據測試結果生成一份簡單的測試報告
  2. 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這款工具封裝的終端信息輸出方法,實際中的使用效果如下:
截屏2023-01-17 下午5.53.07.png
有了這些信息的反饋,給測試者的體驗效果就會好很多了

效果展示

最後附上一個實際的測試效果demo(ps.錄製好視頻demo才發現竟然不支持上傳本地視頻...希望看到sf早日支持吧)

運行測試

測試截圖

測試報告

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.