前端ui自動化測試sdk封裝

款冬發表於2023-01-20

背景

前端業務場景中每次功能釋出都會面臨著相應的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早日支援吧)

執行測試

測試截圖

測試報告

相關文章