puppeteer二維併發隊列解決QA重構對比測試難題

嗯,週末在家抱着沉睡中的寶寶邊翻掘金,翻到一篇介紹puppeteer的文章,聯想到最近正在搞的Client端重構,終覺能作點什麼……javascript

由於此次大規模的重構全面使用了資源權限替代老版本中的硬編碼鑑權,而這些入庫的資源整理所有來自於對老代碼的人爲判斷(得益於以前的Hive架構,每一個模塊重構都是獨立負責人,由其收集資源再合適不過了)。css

但人眼總歸不太可信,QA須要針對30+客戶,平均8個左右的角色,首期20個重構功能用肉眼進行新老界面資源比較測試。粗略計算下時間:按每一個功能5分鐘的比較時間計算,前端

30(客戶)* 8(角色)* 2(新老2個帳號)* 20(功能)* 5(分鐘)=  48000 (分鐘)
複製代碼

也就是800小時,不吃不喝不睡33天/人,真TM驚人。java

而後思惟開始跳脫,遐想,我應該能用puppeteer爲QA小姐姐們作點什麼纔是。node

Puppeteer是什麼?

Puppeteer 是一個 Node 庫,它提供了一個高級 API 來經過 DevTools 協議控制 Chromium 或 Chrome。Puppeteer 默認以 headless 模式運行,可是能夠經過修改配置文件運行「有頭」模式。chrome

它是google爲chrome量身打造的,並且仍是nodejs實現的,想一想就很激動對不對?typescript

Puppeteer能作什麼?

你能夠在瀏覽器中手動執行的絕大多數操做均可以使用 Puppeteer 來完成! 下面是一些示例:數據庫

生成頁面 PDF。npm

抓取 SPA(單頁應用)並生成預渲染內容(即「SSR」(服務器端渲染))。api

自動提交表單,進行 UI 測試,鍵盤輸入等。

建立一個時時更新的自動化測試環境。 使用最新的 JavaScript 和瀏覽器功能直接在最新版本的Chrome中執行測試。

捕獲網站的 timeline trace,用來幫助分析性能問題。

測試瀏覽器擴展。

個人設想

基於Hive的子項目管理功能,封裝一個相似於分佈式的自動化測試框架。

  • 首要目的是解決資源比對的問題;
  • 框架能自動幫每個子項目登陸新老帳號,並自動導航到子項目頁面;
  • 分發下去,由子項目的負責人實現本身的資源採集與截屏操做;
  • 管理每一個功能-客戶-角色的對應資源數據;
  • 功能-客戶-角色的對應位置生成測試報表與截屏,特別是報表,包含了新老功能的資源比對,匹配不上的會標紅,還會計算匹配百分比
  • 提供一些API給子項目使用,譬如:
    • 屏幕截取,puppeteer提供的原生截屏api須要手動指定生成位置,我提供的已經預置好生成位置,並極大的簡化了api,好比api.screenshot(name)便可生成一張名爲name_newname_old的截屏文件到相應位置;
    • 添加資源,當操做權交由子項目接管時,他們能使用puppeteer在頁面上抓取指定的資源元素,調用添加資源的api將其添加,最終生成新老資源比對報表;
    • 其它一些簡化過的api等。
  • 相似於分佈式系統同樣,框架會去挨個調用各個子項目的實現,最終生成詳細報表與大綱報表

Puppeteer 安裝指南

Puppeteer須要node版本v7.6.0以上才支持async/await,建議安裝最新stable版本。

安裝Puppeteer時會自動下載最新的Chromium(~71Mb Mac, ~90Mb Linux, ~110Mb Win),這一步對於國內的網絡而言是很是不友好的。

能夠在安裝前執行如下命令避免自動下載Chromium:

npm config set puppeteer_skip_chromium_download true
複製代碼

安裝puppeteer與typescript支持。

npm install puppeteer @types/puppeteer --save-dev
複製代碼

使用方法

const browser = await puppeteer.launch({
    executablePath: 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
    headless: false, // 無頭模式
    timeout: 0, // 超時時間
    // devtools: true, // 自動打開devtools面板
    defaultViewport: defaultViewport, // 默認窗口尺寸
    args: ['--start-maximized'], // 瀏覽器默認參數 [全屏]
  });
複製代碼

對於忽略了Chromium內核下載的狀況,須要加上executablePath這個屬性,手動指定本地的chrome執行文件位置。

headless屬性默認爲true,表明是否啓動無頭模式(不使用瀏覽器界面直接進行測試)

咱們來了解一下Puppeteer的API

有了上面launch的瀏覽器實例以後,建立一個新頁籤:

const page = browser.newPage();
複製代碼

多數時候咱們都是在跟page對象打交道,好比:

前往某頁面

await page.goto(`${context}/Account/Login`, {
    waitUntil: "networkidle0"
  });
複製代碼

第二個是可選參數,拿waitUntil舉例,意思是await直到沒有任何網絡請求時才繼續執行以後的代碼。

它有四個可選值,分別以下:

  • load - 頁面的load事件觸發時
  • domcontentloaded - 頁面的DOMContentLoaded事件觸發時
  • networkidle0 - 再也不有網絡鏈接時觸發(至少500毫秒後)
  • networkidle2 - 只有2個網絡鏈接時觸發(至少500毫秒後)

在登陸頁,咱們須要操做表單,輸入一些內容

await page.type('#userName', account.username);
  await page.type('#password', account.password);
  await page.click('button[type=submit]');
複製代碼

type方法意味着鍵入,第一個參數是元素選擇器(input元素),第二個參數是須要輸入的內容

click方法表明觸發某選擇器指定元素的點擊事件(除了click,還有hover、focus、press等事件)

puppeteer有幾個特色:

1,全部的操做都是異步的,都須要使用asyncawait去調用。由於它是基於chrome DevTools協議的,與chrome的互相調用都是靠發送異步消息。

2,大量的api依賴選擇器,因此css選擇器須要瞭解;

3,有一部分隱藏api沒有在文檔上體現出來,好比如何打開隱身模式,如何清除cookie等。

當完成表單輸入與點擊提交以後,咱們須要跳轉到子項目頁面去,可是,在這以前咱們須要等待登陸操做完成才行

await page.waitForNavigation({
    waitUntil: "domcontentloaded"
  });
複製代碼

此處,咱們看到waitUntil的第二個枚舉值了domcontentloaded,它的意思是和document的DOMContentLoaded事件是同樣的意思。瞭解前端開發的童鞋應該都清楚它與onload事件的差別,我這裏就很少說了,反正比onload時間點靠前不少。

獲取頁面元素

const input = await page.$('input.form-input');
  const buttons = await page.$$('button');
複製代碼

page.$ 能夠理解爲咱們經常使用的 document.querySelector, 而 page.$$ 則對應 document.querySelectorAll

// 獲取視窗信息
  const dimensions = await page.evaluate(() => {
      return {
          width: document.documentElement.clientWidth,
          height: document.documentElement.clientHeight,
          deviceScaleFactor: window.devicePixelRatio
      };
  });
  const value = await page.$eval('input[name=search]', input => input.value);
複製代碼

page.evaluate 意爲在瀏覽器環境執行腳本,可傳入第二個參數做爲句柄,而 page.$eval 則針對選中的一個 DOM 元素執行操做。

此外,還有一個功能頗有意思page.exposeFunction暴露函數

const puppeteer = require('puppeteer');
  const crypto = require('crypto');
  
  puppeteer.launch().then(async browser => {
    const page = await browser.newPage();
    page.on('console', msg => console.log(msg.text));
    await page.exposeFunction('md5', text =>
      crypto.createHash('md5').update(text).digest('hex')
    );
    await page.evaluate(async () => {
      // use window.md5 to compute hashes
      const myString = 'PUPPETEER';
      const myHash = await window.md5(myString);
      console.log(`md5 of ${myString} is ${myHash}`);
    });
    await browser.close();
  });

複製代碼

上面的例子展現瞭如何暴露一個md5方法到window對象上

由於nodejs有不少工具包能夠很輕鬆的實現很複雜的功能,好比要實現md5加密函數,這個用純js去實現就不太方便了,而用nodejs倒是幾行代碼的事情。

開始設計咱們的框架

步驟貼出來

  • 準備一份帳號列表(包含新老版本),獲取這個列表;
  • 使用lerna ls獲取全部的子項目列表,轉而尋找其實現了的xxxx/autotest/index.ts文件;
  • 循環帳號,每一個帳號登陸進去,再循環全部子項目,挨個導航進去,將操做權以及準備好的pageapi對象交給子項目。
    • 子項目收集資源與截屏
  • 結束操做,將收集到的全部資源分門別類的按功能-客戶-角色位置生成新老對比報表
  • 最後生成大綱報表

其實步驟很是簡潔明瞭。

先貼一下最終的報表截圖

詳情報表

詳情報表

大綱報表

大綱報表

新老資源對比,有差別的地方都標紅處理,並給出各自的資源總數與匹配百分比,相似於Jest單元測試生成的測試報告。

再貼一下主要實現代碼

const doAutoTest = async (page: puppeteer.Page, resource: IAllProjectResource, account: IAccount, projectAutoTestList: IAutoTest[], newVersion: boolean) => {
    await login(page, account);
    const clientCode = await getCookie(page, 'ClientCode');
    const clientDevRole = await getCookie(page, 'ClientCurrentRole');
    for(const autotest of projectAutoTestList) {
      await page.goto(`${context}${autotest.url}`, {
        waitUntil: "domcontentloaded"
      });
      const api: IpuppeteerApi = {
        logger: logger,
        screenshot: screenshot({ page, newVersion, clientCode, clientDevRole, subProjectName: autotest.name }),
        addResource: addResource({ newVersion, clientCode, clientDevRole, subProjectName: autotest.name, projectResource: resource, username: account.username }),
        isExist: isExist({ page })
      };
      !newVersion ? await autotest.oldVersionTest(page, api) : await autotest.newVersionTest(page, api);
    }
  };

  (async () => {
    const browser = await launch();
    const projectAutoTestList = getSubProjectAutotests();
    const accounts = getAccounts();
    const resource = getResource();
    const page = await createPage();
    for(const accountGroup of accounts) {
      await doAutoTest(page, resource, accountGroup.oldVersionAccount, projectAutoTestList, false);
      await doAutoTest(page, resource, accountGroup.newVersionAccount, projectAutoTestList, true);
    }
    
    exportHtml(resource);
    await browser.close();
  })();
複製代碼

是否到這裏就結束了?

No,我可不是個標題黨。做爲一個不折騰會死的技術宅,我以爲還能夠更進一步,作到極致!

目前咱們的自動化測試是挨個帳號登入,並挨個功能進行測試。固然,對於這個流程來講是沒問題的,人類也是這樣測試的,只不過機器不用休息,「手速」更快而已……

但,

CPU跑滿了嗎?

內存跑滿了嗎?

機器發揮了應有的價值了嗎?

徹底沒有。

是的,也許你猜到了,多任務+併發

起初我會想,對於chrome而言,咱們打開的瀏覽器是共享session的,隨便看了看官方文檔沒發現有新建session相關的api……

那咱們仍然能夠去實現登陸單個帳號,開多個頁籤同時測多個功能吧!

答案是能夠的,但我仍然不死心,google了一圈,還真發現了一個隱藏api

const { browserContextId } = await browser._connection.send('Target.createBrowserContext');
  _page = await browser._createPageInContext(browserContextId);
  _page.browserContextId = browserContextId;
複製代碼

經過發送Target.createBrowserContext指令(暫且稱之爲指令吧),能夠建立一個新的上下文(用瀏覽器的功能來講就是建立一個隱身模式)。而後經過browser._createPageInContext(browserContextId)就能夠獲得這個新的隱身模式窗口對象!

經過這個api,我能夠建立無數多個session隔離的page對象!

有了這個api,我就能夠實現n*m二維併發(併發多個帳號登陸,每一個帳號併發多個功能測試)

首先,我須要稍稍改造一下代碼

大部分的代碼不須要改動,把它們看待成任務便可,咱們須要添加的是任務調度邏輯。

實現一個TaskQueue隊列

export default class Task<T = any> {

  executor: () => Promise<T>;

  constructor(executor?: () => Promise<T>) {
    this.executor = executor;
  }

  async execute() {
    return this.executor && await this.executor();
  }
}

export default class TaskQueue<T extends Task> {

  concurrence: number;
  queue: T[] = [];

  constructor(concurrence: number = 1) {
    this.concurrence = concurrence;
  }

  addTask(task: T | T[]) {
    if (Object.prototype.toString.call([]) === '[object Array]') {
      this.queue = [...this.queue, ...task as T[]];
    } else {
      this.queue.push(task as T);
    }
    return this;
  }

  async run() {
    const todos = this.queue.splice(0, this.concurrence);
    if (todos.length === 0) return;
    await Promise.all(todos.map(task => task.execute()));
    return await this.run();
  }
}
複製代碼

再實現一個AccountTask

import Task from "./task";
import puppetter from 'puppeteer';
import ProjectTask from "./projectTask";
import TaskQueue from "./taskQueue";
import { IAccount, IAutoTest, getCookie, IpuppeteerApi, screenshot, addResource, isExist } from "../utils/api";
import { max_page_number, context } from "../utils/constant";
import logger from "../utils/logger";
import login from "../login";
import { createPage, pool } from "../browser";
import { getResource } from "../config/config";

const resource = getResource();

export default class AccountTask extends Task {
  
  account: IAccount;
  page: puppetter.Page;
  projects: IAutoTest[];
  taskQueue: TaskQueue<ProjectTask>;
  newVersion: boolean;

  constructor(account: IAccount, projects: IAutoTest[], newVersion: boolean) {
    super();
    this.account = account;
    this.projects = projects;
    this.taskQueue = new TaskQueue(max_page_number);
    this.newVersion = newVersion;
    this.initTaskQueue();
  }

  initTaskQueue() {
    this.taskQueue.addTask(this.projects.map((autotest, index) => new ProjectTask(async () => {
      const page = await createPage(true, this.page);
      const clientCode = await getCookie(this.page, 'ClientCode');
      const clientDevRole = await getCookie(this.page, 'ClientCurrentRole');
      await page.goto(`${context}${autotest.url}`, {
        waitUntil: "domcontentloaded"
      });
      const api: IpuppeteerApi = {
        logger: logger,
        screenshot: screenshot({ page, newVersion: this.newVersion, clientCode, clientDevRole, subProjectName: autotest.name }),
        addResource: addResource({ newVersion: this.newVersion, clientCode, clientDevRole, subProjectName: autotest.name, projectResource: resource, username: this.account.username }),
        isExist: isExist({ page })
      };
      !this.newVersion ? await autotest.oldVersionTest(page, api) : await autotest.newVersionTest(page, api);
      await page.close();
    })));
  }
  
  async execute() {
    this.page = await createPage(true);
    await login(this.page, this.account);
    await this.taskQueue.run();
    this.page.close();
  }

}
複製代碼

而後改造一下主函數

if (argv.mode === 'crazy') {
    const accountTaskQueue = new TaskQueue(max_isolation_number);
    for(const accountGroup of accounts) {
      accountTaskQueue.addTask([
        new AccountTask(accountGroup.oldVersionAccount, projectAutoTestList, false),
        new AccountTask(accountGroup.newVersionAccount, projectAutoTestList, true)
      ]);
    }
    await accountTaskQueue.run();
  } else {
    const page = await createPage();
    for(const accountGroup of accounts) {
      logger.info(`start old version test`);
      await doAutoTest(page, resource, accountGroup.oldVersionAccount, projectAutoTestList, false);
      logger.info(`start new version test`);
      await doAutoTest(page, resource, accountGroup.newVersionAccount, projectAutoTestList, true);
    }
  }
複製代碼

這裏配置了兩個常量用於控制併發閾值

export const max_isolation_number = 5; // session隔離(帳號)最大併發數

  export const max_page_number = 5; // 子項目最大併發數
複製代碼

判斷若是爲雞血(crazy)模式,則建立一個accountTaskQueue隊列,載入帳號登陸任務。AccountTask內部會初始化另外一個隊列taskQueue,用於處理子項目任務隊列。

我管它叫打雞血,由於實際跑起來確實有些嚇人。

而後啓動帳號隊列便可。

和預想的同樣,當accountTaskQueue啓動時,立馬啓動了5個隱身模式。而AccountTask內部的taskQueue又會各自併發5個子項目任務進行測試。

速度很是快,完成一次測試比剛剛至少快了10倍不止!!!

但是仔細想了一下,發現仍然有一個小問題,你們能夠觀察一下下面這段隊列控制代碼

async run() {
    const todos = this.queue.splice(0, this.concurrence);
    if (todos.length === 0) return;
    await Promise.all(todos.map(task => task.execute()));
    return await this.run();
  }
複製代碼

這段代碼最大的問題在於await Promise.all,每次取最大併發數(5個)同時執行,但我沒有考慮過動態補充剩下的進去執行,而是傻傻的等到5個都執行結束再取5個執行。

這樣就浪費了時間與CPU性能。

可是這裏又是個異步併發隊列,動態補充並非太好處理。

權衡了一下子,仍是不造輪子了,找了個國外大神sindresorhus寫的庫p-limit,從新處理了一下這裏

import pLimit, { Limit } from 'p-limit';

export default class TaskQueue<T extends Task> {

  concurrence: number;
  queue: T[] = [];
  limit: Limit;

  constructor(concurrence: number = 1) {
    this.concurrence = concurrence;
    this.limit = pLimit(concurrence);
  }

  addTask(task: T | T[]) {
    if (Object.prototype.toString.call([]) === '[object Array]') {
      this.queue = [...this.queue, ...task as T[]];
    } else {
      this.queue.push(task as T);
    }
    return this;
  }

  async run() {
    return await Promise.all(this.queue.map(task => this.limit(async () => await task.execute())));
  }
}
複製代碼

它的做用主要是幫咱們起到一個隊列的併發限制做用,等因而把隊列的功能轉交給它了。

這時候咱們再運行代碼看看,起初是5個,某個窗口執行完了自動關閉以後立刻又補了一個新的進來。嗯,這是咱們想要的結果了。

還有優化空間嗎?

有!咱們每個隱身模式任務執行完畢以後都會關閉,而後又從新申請建立,這也形成了資源與時間的浪費。

怎麼辦呢?

我想到了數據庫的鏈接池

我能夠將這些隱身模式的page對象放進池裏面,須要的時候去取,不用了從新放回池子裏就好了。

修改AccountTask的執行函數execute以下

async execute() {
    this.page = await pool.getMainPage();
    await login(this.page, this.account);
    await this.taskQueue.run();
    pool.recycle(this.page);
  }
複製代碼

又運行了一遍,計算了一下時間,運行8個帳號2個功能,時間從70秒提高到了48秒!

至此,咱們不只完成了最初的目標,還提供了普通雞血兩種模式。

普通模式用來開發與調試,雞血模式用於實際應用。

特別是雞血模式,纔是此次文章要表達的精髓所在,它實現了一個二維併發隊列,這是前端工程師在平常工做中比較少接觸到的。

相關文章
相關標籤/搜索