最近接觸某個重要專案,這專案很特別,每個狀態都會對應不同 config 有不同回應,網頁操作、業務邏輯十分之複雜,大概就像是 tree structure 一樣。我負責翻新頁面的 login flow,雖然我對改動邏輯掌握度高,但我滿擔心是否有沒看到的功能,會關聯到舊邏輯。

因為 use case 實在太繁雜了,每次都需要請 PM 或工程師手動測試,但又常常漏掉某些特殊情境,讓我有感而發,如果有跑測試就好了。有剛好有時間就來研究一下,要如何在 react 專案上跑 End to End Test。

Puppeteer 介紹

Puppeteer 是由 Chrome DevTools team 團隊開發的,它是一個 node library 工具,提供 API 讓我們控制 chrome 或 Chromium,並能以 headless chrome(chrome without chrome) 或正常模式執行。相對於 Selenium 專注在跨瀏覽器測試,Puppeteer 則是專注依賴 Chromium 並使用相關的 API 測試。

主要功能

  1. 產生 pdf 網頁快照。
  2. render 頁面是基於 single page application 架構的頁面,並產生靜態內容。
  3. 自動化測試,input、鍵盤、UI 測試。
  4. 能夠使用最新的 chrome 環境進行測試。
  5. 產生頁面優化診斷。
  6. 測試 chrome extension。

Puppeteer 官方介紹

這個需要測試的頁面是以 React 開發,純 client side render,E2E 測試也需要處理 render SPA,適合我們的情境。

安裝環境

建立資料夾,並安裝 package.json 、 puppeteer 等相關設定,接下來建立 js 檔案來使用 node 測試 puppeteer。

mkdir test-puppeteer cd test-puppeteer npm init -f npm install --save-dev puppeteer dotenv touch test.js

執行頁面載入 screenShot

接下來嘗試載入 google 頁面,並且自動輸入值來使用 google search。首先我們需要 引入 puppeteer,並執行 puppeteer.launch 啟動 puppeteer,再利用 goto 來載入 https://google.com 頁面,接下來執行 screenshot 並命名 image 儲存位置,接下來 執行 node test,成功跑起來的話會發現新增了 screenshot_google.png ,打開來就是模擬 screenshot 的畫面。

Puppeteer 文件

  • google.js
const puppeteer = require("puppeteer"); (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto("https://google.com"); await page.screenshot({ path: "./screenshot_google.png" }); await browser.close(); })();

模擬網頁互動

使用 page.evaluate(function) 功能,可以讓我們執行 javascript,這邊我們會使用 javascript DOM selector 輸入搜尋框,並且點擊送出搜尋按鈕,再來 page.waitForNavigation() 等待頁面導覽完成,再來繼續執行 evaluate 點擊第一個搜尋結果的標題,最後執行 screenShot。

簡單講就是用 JavaScript 模擬使用者互動操作,來促使畫面更新變動,再截圖存取畫面。

page.evaluate 可以執行 javascript,直接以 selector 操作,這邊模擬輸入 coronavirus,並且執行 click submit,接下來 page.waitForNavigation 等待網頁導覽,接下來 page.evaluate 執行第一篇搜尋結果標題 click,最後執行 page.screenshot,截圖儲存並關閉。就完成了一個簡易的 puppeteer 測試。

  • google.js
(async () => { ... await page.evaluate(() => { document.querySelector("input[type='search']").value = "coronavirus"; document.querySelector("button[jsaction='click:.CLIENT']").click(); }); await page.waitForNavigation(); await page.evaluate(() => { document.querySelector('#rso div[role="heading"]').click(); }); await page.waitForNavigation(); await page.screenshot({ path: "./screenshot_google_search.png" }); await browser.close(); })();

googlecorona

puppeteersandbox google Demo

測試 facebook login

接下來測試一直想跑的 facebook login,曾經某天 facebook 偷偷改版 api,造成整個產品的 login 服務掛掉,或是前幾個月 facebook 連線就出問題了,但這都是在客戶端回報才發現到問題,所以期待這段以 cronjob 定時 run,來確保登入功能是正常的。

首先載入頁面,假設是手機版,頁面會自動跳出登入按鈕,按鈕上有寫上特殊的 attribute id=“e2e-login-button”,接下來等待這個按鈕顯示,可以使用 page.waitForSelector 等待這個 element visible,執行 button click。

倒轉到 facebook 網頁後,用 page.evaluate 執行 javascript,填入 facebook 帳號、密碼,再來點擊 facebook 登入按鈕,登入完成後會出現授權頁面,同樣點擊確認授權按鈕。

倒轉回到原本網站,接下來就判斷是否顯示 登出按鈕,確保使用者有完成登入流程。

核心邏輯大概就是 waitForSelector 等待 DOM render,並且 page.evaluate 執行互動,在等待 DOM render,執行互動 loop,達成我們所期待的畫面滾動。

  • fbLogin.js
require("dotenv").config(); const puppeteer = require("puppeteer"); const devices = require("puppeteer/DeviceDescriptors"); (async () => { const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.emulate(devices["iPhone 7"]); await page.goto("https://www.feversocial.com/promo/join?promoid=134364"); await page.waitForSelector("#e2e-login-button", { visible: true }); await page.click("#e2e-login-button"); await page.waitForSelector("#email_input_container", { visible: true }); await page.evaluate( (account, password) => { document.querySelector("#email_input_container input").value = process.env.FBACCOUNT; document.querySelector("input[type='password']").value = process.env.FBPASSWORD; document.querySelector("button[name='login']").click(); }, process.env.FBACCOUNT, process.env.FBPASSWORD ); await page.waitForSelector("button[name='__CONFIRM__']", { visible: true }); await page.evaluate(() => { document.querySelector("button[name='__CONFIRM__']").click(); }); await page.waitForSelector("div[type='0']", { visible: true }); await page.waitForFunction( `document.querySelectorAll('div[type="0"]')[1].textContent === '登出'` ); await page.screenshot({ path: "example.png", fullPage: true }); await browser.close(); })();

fever fblogin

提醒一下,這個測試情境只針對曾經給予過 facebook auth 權限,因為跑完每次還要 reset auth 非常不方便。

  • 記得修改 facebook 帳號密碼 ( 如果擔心線上測試有資安疑慮,就避免使用,我是都用專門測試的帳號 )

puppeteersandbox facebook login Demo

搭配 Jest 測試

上述都是使用 puppeteer 建出流程,但實際上還需要搭配測試斷言工具,這樣才能讓判斷出測試 case 是不是正常,如果不正常的話是哪個流程有問題等等。

這邊使用到 Jest,環境設定與之前 Jest 大同小異,主要是需要安裝支援 puppeteer 的工具,讓 Jest 能夠執行 puppeteer 環境,基本上沒有太大的難度。如果遇到問題的話 Jest 官網也有 source code 可以提供下載。

  • 安裝 Jest
npm install --save-dev jest-puppeteer jest

建立 jest.config.js, jest.setup.js

  • jest.config.js
module.exports = { preset: "jest-puppeteer", setupFilesAfterEnv: ["./jest.setup.js"] };

設定環境以及應用 setup 設定

  • jest.setup.js
jest.setTimeout(10000);

延長 jest default 8000 msec 時間限制

Jest with Puppeteer

Puppeteersandbox github sample code

建立測試檔案

首先建立 _test_ folder,接下來建立 login.spec.js,針對整個登入流程做判斷,預期之外的狀況就截圖,並且拋出相對錯誤訊息。Jest 無法跳出瀏覽器模擬,所以直接設定 headless 即可。

將上面的 fbLogin.js script 貼上來作為修改,waitForSelector 增加 timeout,避免卡在一個狀態過久浪費時間,增加 try catch,針對錯誤進行截圖,方便出問題時 debug。

其餘與一般的 jest 差別不大,就是在預期發生錯誤增加斷言判斷等,這邊就不特別介紹了。實際內容再請觀看 github 上檔案。

小雷,process 我卡了大概 30 分鐘,process.env.FBPASSWORD 要用 function 作為參數帶入,直接在 evaluate callback function 內,因為環境緣故,是無法取得 process.env 的值。還有其他 dom selector 怎樣寫比較不會出錯等等,這是最耗時的部分,因為是 side project,沒有直接去改 dom 的 tag attribute,因為滿多強硬的 selector 寫法,建議大家還是好好 naming 狀態。

  • login.spec.js
... beforeEach(async () => { await page.goto( "https://www.feversocial.com/promo/join?promoid=134230" ); }); describe("Login", () => { it("Fever Login Flow", async done => { await page.emulate(devices["iPhone 7"]); await page.waitForSelector("#e2e-login-button", { visible: true, timeout: 1500 }); try { const buttonHref = await page.$eval('#e2e-login-button', el => el.href); await expect(buttonHref).not.toBe(''); } catch (error) { await page.screenshot({ path: "./promo_button_href_error.png" }); } await page.click("#e2e-login-button"); ... await page.evaluate((account, password) => { document.querySelector("#email_input_container input").value = account; document.querySelector("input[type='password']").value = password; document.querySelector("button[name='login']").click(); }, process.env.FBACCOUNT, process.env.FBPASSWORD); ...

Source Code : puppeteer-jest-example

心得

這些看起來很簡單,但這些我大概花了 3 天時間,雖然撥出來的時間零零碎碎,剛好某天開發時間有多出時間,就來嘗試用 puppeteer 寫一個測試登入,實際上未來要在搭配 docker 部署上去並以專案 release 時自動觸發,然後出現錯誤要 alarm,這有空再嘗試研究…。

以上如果有問題歡迎留言,感謝。