個人開發的 Minecraft 伺服器監控應用程式「MineWatch」(官方網站、應用程式全貌請見這裡)具有 Web、admin、iOS 這三種不同的技術堆疊。前一篇文章介紹了在 Jenkins 的 PR 審查中執行測試與掃描的機制,但當時 E2E 只是作為驗證項目之一被提到而已。這篇文章要介紹的是,分別替它們建立 E2E 測試(Web/admin 使用 Playwright、iOS 使用 XCUITest)的過程。跨越了如何繞過驗證的第一道難關後,接著是測試程式本身寫法的陷阱,然後還有 Jenkins 全新執行環境特有的陷阱在等著。
String(localized:) 時,會去找測試 bundle 自己的字串,而不是 app 本體的字串。另外,彈出視窗與其背後的列表,兩邊的 navigation bar 都會留在可存取性樹中,因此未指定的查詢會讓按鈕歸屬變得含糊。MineWatch 的 API 已經透過 Postman/Newman 完成了端點的 E2E 測試。我希望把這個想法延伸到 Web、admin、iOS 的 UI,讓它們在 push 前、PR 前都能在本機自動完成一輪確認,這就是起點。
首先決定的是,實際的 Firebase 驗證與實際的 App Check 本身,刻意不納入測試範圍。這和 Postman/Newman 的做法一致。如果不能繞過驗證相關流程,就無法自動化驗證 UI 的主要流程(新增、編輯、刪除、設定變更)。
Web/admin 透過 Firebase Auth 的自訂權杖來建立已驗證狀態。
/**
* E2E 測試專用的固定 Firebase 使用者。為了不與真實使用者衝突,
* UID 明確設計成一看就知道是測試用。
*/
export const E2E_TEST_UID = "e2e-playwright-test-user";
/**
* 發行 E2E 測試用使用者的 Firebase 自訂權杖。
*/
export async function issueE2eCustomToken(): Promise<string> {
ensureAdminApp();
return getAuth().createCustomToken(E2E_TEST_UID);
}
使用 Firebase Admin SDK 在伺服器端發行自訂權杖,再在瀏覽器端傳給 signInWithCustomToken。這樣就能走過真正的 Firebase Auth 流程,同時避開輸入電子郵件/密碼或執行社群登入本身。
另一個難題是 App Check。Firebase App Check 的 Web SDK 可以透過把 debug token 設定到全域變數中,來不經由 reCAPTCHA 取得 token。我把這個 appCheckDebugToken 透過 Playwright 的 fixture 注入到瀏覽器中。
導入 Playwright 的第一個 PR(#258)中,web 端先實作了最小且具代表性的流程:伺服器註冊 → 列表顯示 → 刪除;admin 端則做了 儀表板顯示 的 smoke test,先把模式建立起來。後續 PR(#261)再補上 web 端的編輯、通知設定變更,以及 admin 端的裝置列表、聯絡我們處理等其餘主要流程。
著手 iOS 端的 XCUITest 時,我原本以為要先做一套「不經過 Firebase 真實登入就能跑測試的啟動參數」。但實際開始之後才發現,UITEST_STUB_AUTH 這個啟動參數、專用的 XCUITest target、MockServerRepository / MockAccountRepository 這些 mock repository,其實早就為了截圖測試(ScreenshotTests.swift)準備好了。issue 上寫的「想做的事」其實大多已經完成,這次主要工作只是補上實際的測試案例。
新增的是伺服器的註冊、刪除、編輯,以及 crash report 同意開關這 4 個測試。在這個過程中,測試還真的找到了產品的實際 bug。在詳細頁編輯伺服器名稱後返回列表,顯示名稱沒有更新。原因是顯示列表的 ViewController 只在 viewDidLoad 時載入列表;新增時雖然有明確呼叫重新載入,但從編輯返回時卻沒有走同一套流程,實作上出現了不對稱。後來加入 viewWillAppear 的重新載入後就修正了。也就是說,撰寫測試本身就成了找出實作 bug 的契機。
實作過程中,有兩個必須實機確認才會知道的陷阱。
第一個是從測試 target 存取本地化字串時的行為。用 String(localized:) 檢查畫面標題時,查找的不是 app 本體的字串資源,而是測試 bundle 自己的字串資源。由於測試 bundle 當然沒有那些字串,最後回傳的就只是 key 字串本身。對策很單純:畫面標題直接寫死日文的字串字面值。
第二個是彈出視窗與其背後畫面,兩邊的 navigation bar 會同時留在可存取性樹中。伺服器註冊表單是以 modal 方式疊在列表上,但列表那邊的 navigation bar 並不會消失而是仍留在樹中。若寫成像 app.navigationBars.buttons 這種沒有指定是哪個 navigation bar 的查詢,就會變成無法判斷究竟是 modal 那邊的按鈕,還是背後畫面的按鈕,實際上甚至會發生誤點。對策是用像 app.navigationBars["サーバー"] 這樣,直接以標題明確指定目標 navigation bar。
一開始,把它納入 CI 的範圍是「先以本機執行為主,是否放進 CI 之後再決定」。不過實際運用後發現,只在本機、push 前跑測試,無法找出 CI 環境特有的問題。最後我把它整合進 Jenkins 的 PR 審查 pipeline,只有在偵測到 web/admin 有變更時,才會執行 E2E stage。
這個整合完成後,我在驗證另一個 PR(導入 Trivy 映像漏洞掃描)時,發現了兩個 Jenkins 全新 workspace 特有的 bug。
第一個是 API container 因為缺少 APNs 金鑰檔而無法啟動 lifespan 的問題。開發機上已經放著該檔案,所以完全沒注意到;直到在全新的 workspace 中才第一次浮現。
第二個是缺少 firebase-config.json。這個檔案屬於 .gitignore(只有 appCheckDebugToken 是秘密),開發機上有實體檔案,但 Jenkins workspace 只有 .sample。由於 nginx 會把這個檔案整個 bind mount 出去,若實體檔不存在,/firebase-config.json 會回傳 404 的 HTML,導致 Playwright 端的 JSON.parse 失敗,連登入流程都無法執行。除此之外,即使登入成功,web 端從瀏覽器直接發送的伺服器註冊還會因為 App Check 強制而被擋下(admin 端是透過伺服器側 API key 呼叫,所以不受影響;web 端是從瀏覽器直接 POST,所以會受影響)。對策是在 make e2e-ci 中加入:若實際檔案不存在,就用 .sample 補上(apiKey 等非秘密值為真實值,只有 appCheckDebugToken 是 placeholder),並且像既有的 docker-compose.postman.yml 一樣,加上 APP_CHECK_ENFORCED=false。
Jenkins 端的 stage 只在 web/admin 有變更時條件式執行,並且從 make e2e-ci 啟動到結束後清理(make e2e-ci-stop)都能自成一體。為了避免像 up 或 migration 這種步驟失敗時遺漏清理,我還加了安全機制:透過標記是否已經開始執行,無論成功或失敗都嘗試進行收尾。
這是以個人開發環境為前提的配置;若要直接套用到組織內,請先確認測試用 Firebase 專案的分離方式,以及憑證管理政策。
JQIT 的工程師中,95% 以上都是從無經驗錄用的。
如果有興趣,也歡迎來公司網站逛逛。
可以從零開始學習!一起挑戰吧!
也有經營 note 和 X ↓
原文出處:https://qiita.com/jqit-yukiono/items/0f7d52531f4370484101