試實(shí)戰(zhàn):從Playwright選型到CI穩(wěn)定性治理)
e2e全稱 end-to-end端到端。過(guò)去半年我一直在折騰這三個(gè)字母——不是寫業(yè)務(wù)代碼而是確保業(yè)務(wù)代碼在真實(shí)用戶路徑上不翻車。如果你正打算給項(xiàng)目接端到端測(cè)試或者已經(jīng)被一套三天兩頭 flaky 的 E2E 套件折磨到懷疑人生那這篇文章大概率能對(duì)你有用。我盡量少講虛的多講實(shí)操包括框架選型、配置文件怎么設(shè)計(jì)、用例怎么寫才不會(huì)變成擺設(shè)、以及把 E2E 塞進(jìn) CI 之后要面對(duì)的一系列糟心事。先說(shuō)結(jié)論E2E 測(cè)試的價(jià)值不是“多點(diǎn)幾個(gè)按鈕”而是把“用戶真的能完成這件事”變成一條可重復(fù)、可追蹤、可回歸的驗(yàn)證鏈路。但它的成本也實(shí)實(shí)在在——跑得慢、穩(wěn)定性難搞、改版就碎。所以這篇文章不只是教你裝個(gè)框架寫兩個(gè)用例而是把這半年里我踩過(guò)的坑、試過(guò)的方案、留存的配置全部攤開給你看。1. 端到端測(cè)試到底在測(cè)什么三層測(cè)試體系里最后那道閘門1.1 一個(gè)購(gòu)物流程三層測(cè)試各自守什么先別急著寫用例。我剛開始接觸 E2E 時(shí)犯過(guò)一個(gè)典型錯(cuò)誤把什么都往 E2E 里塞結(jié)果套件跑一次要 40 分鐘還一改前端就紅一片。后來(lái)才想明白E2E 在整個(gè)測(cè)試體系里的定位不是“替代單元測(cè)試”而是“守住用戶路徑的最后一公里”。拿一個(gè)最常見的購(gòu)物流程舉例。用戶打開商品頁(yè) → 加入購(gòu)物車 → 進(jìn)入結(jié)算頁(yè) → 填寫地址 → 提交訂單 → 跳轉(zhuǎn)支付。這個(gè)鏈路里單元測(cè)試負(fù)責(zé)的是“購(gòu)物車 reducer 的狀態(tài)對(duì)不對(duì)”“價(jià)格計(jì)算函數(shù)有沒有精度問題”集成測(cè)試負(fù)責(zé)的是“后端接口和前端頁(yè)面能不能正確對(duì)接”而 E2E 只回答一個(gè)問題如果我真是一個(gè)拿著真實(shí)瀏覽器的用戶從商品頁(yè)一路點(diǎn)到支付成功系統(tǒng)會(huì)不會(huì)在某個(gè)環(huán)節(jié)斷掉這三層不是互斥關(guān)系而是互補(bǔ)關(guān)系。我習(xí)慣把它們想成做飯的過(guò)程單元測(cè)試驗(yàn)證鹽和糖各自有沒有問題集成測(cè)試驗(yàn)證鍋和灶臺(tái)搭不搭E2E 則是把整桌菜端上桌請(qǐng)一個(gè)人從頭吃到尾——哪怕每樣食材單獨(dú)都沒問題也得確認(rèn)真吃的時(shí)候不會(huì)噎住。1.2 E2E 不是“點(diǎn)點(diǎn)點(diǎn)”而是“用戶路徑的完整承諾”有一個(gè)認(rèn)知誤區(qū)要提前破除E2E 測(cè)試的價(jià)值并不在于“測(cè)出 bug”而在于暴露集成斷裂。比如前端改了接口字段后端還沒跟上或者某個(gè)頁(yè)面的路由因?yàn)橹貥?gòu)被改掉了或者 localStorage 里存的 token 過(guò)期時(shí)間變了導(dǎo)致流程中途跳回登錄頁(yè)。這些問題在單元測(cè)試?yán)锔緹o(wú)法被發(fā)現(xiàn)因?yàn)樗鼈儼l(fā)生在模塊與模塊的交互層。所以設(shè)計(jì) E2E 用例的核心思路不是“盡量覆蓋所有頁(yè)面”而是“覆蓋所有關(guān)鍵用戶旅程”。像注冊(cè)登錄、下單支付、搜索篩選、個(gè)人信息修改這些是用戶高頻操作的核心路徑必須作為 E2E 的常駐用例。而像后臺(tái)管理系統(tǒng)的某個(gè)只給內(nèi)部員工用的導(dǎo)出按鈕如果日常改動(dòng)頻率低就可以不塞進(jìn) E2E省下的執(zhí)行時(shí)間給真正的關(guān)鍵路徑。1.3 什么項(xiàng)目暫時(shí)別急著上 E2E我得勸退一些人如果你的項(xiàng)目還在頻繁調(diào)整信息架構(gòu)頁(yè)面跳轉(zhuǎn)關(guān)系一個(gè)月變一次這時(shí)候倉(cāng)促鋪開 E2E 只會(huì)寫出大量上線即廢的用例。E2E 適合的核心前提是核心用戶路徑相對(duì)穩(wěn)定。另外如果團(tuán)隊(duì)連單元測(cè)試都還沒有積累我建議先把單測(cè)補(bǔ)起來(lái)再碰 E2E否則你會(huì)在一個(gè)全是 bug 的地基上蓋房越改越痛苦。我的經(jīng)驗(yàn)是先圈定 3~5 條最核心的用戶旅程用最輕量的方式跑通再逐步擴(kuò)展。一上來(lái)就追求覆蓋率只會(huì)讓維護(hù)成本爆炸。覆蓋率這個(gè)東西在 E2E 領(lǐng)域里參考意義遠(yuǎn)沒有單測(cè)那么大——一個(gè)覆蓋了 100 個(gè)頁(yè)面的 E2E 套件很可能不如 5 條精心設(shè)計(jì)的核心旅程有價(jià)值。2. 工具選型為什么我從 Selenium 遷移到了 Playwright2.1 三個(gè)主流框架的橫評(píng)E2E 框架的選擇直接決定你后面半年是省心還是糟心。這里先把主流的三個(gè)方案擺出來(lái)做個(gè)對(duì)比維度SeleniumCypressPlaywright支持語(yǔ)言Java、Python、C#、JS 等僅 JavaScript/TypeScriptJavaScript/TypeScript、Java、Python、C#架構(gòu)通過(guò) WebDriver 協(xié)議控制瀏覽器代碼與瀏覽器分離運(yùn)行在瀏覽器內(nèi)的 Node 進(jìn)程橋接通信通過(guò) CDP 等協(xié)議直連瀏覽器無(wú)獨(dú)立 server多標(biāo)簽頁(yè)/多瀏覽器支持但需要手動(dòng)切換句柄早期不支持多標(biāo)簽現(xiàn)代版本有限支持原生支持多頁(yè)面、多上下文自動(dòng)等待較弱需要大量顯式等待較強(qiáng)自動(dòng)重試查詢直到元素出現(xiàn)最強(qiáng)內(nèi)置自動(dòng)等待 斷言重試調(diào)試體驗(yàn)一般需要借助第三方報(bào)告有時(shí)間旅行回放體驗(yàn)很好有 Trace Viewer可查看完整網(wǎng)絡(luò)調(diào)用與 DOM 快照并發(fā)能力依賴外部 runner支持但多瀏覽器有局限原生支持并行 worker 與分片網(wǎng)絡(luò)攔截需要代理方案支持原生支持路由攔截極其方便我知道現(xiàn)在很多老項(xiàng)目還在用 Selenium因?yàn)閳F(tuán)隊(duì)里 Java 背景的人多或者歷史用例已經(jīng)積累了上千條。但如果從零開始選型我強(qiáng)烈建議在 Playwright 和 Cypress 之間選。Cypress 在純前端團(tuán)隊(duì)的體驗(yàn)很舒服DSL 直觀、調(diào)試界面友好而 Playwright 在大型套件、多瀏覽器兼容、網(wǎng)絡(luò)模擬、性能方面的上限更高。2.2 我選 Playwright 的三個(gè)決定性理由我以前是 Selenium 的深度用戶用 Python 寫過(guò)幾百條用例。最后遷移到 Playwright決定性理由有三個(gè)。第一是自動(dòng)等待機(jī)制。Selenium 里最常見的噩夢(mèng)就是sleep(3)賭頁(yè)面加載完或者寫一堆WebDriverWait條件。Playwright 對(duì)元素狀態(tài)有默認(rèn)自動(dòng)等待點(diǎn)擊前會(huì)等元素可見、可命中、穩(wěn)定頁(yè)面跳轉(zhuǎn)時(shí)會(huì)等待導(dǎo)航完成。這套機(jī)制極大地減少了 flaky 用例寫起來(lái)手順得多。第二是測(cè)試隔離的粒度。Playwright 提出了 browser context 的概念。一個(gè) context 內(nèi)的 cookie、localStorage、緩存都是完全隔離的。這意味著我每條用例都能開一個(gè)“干凈的瀏覽器環(huán)境”不用手工清理數(shù)據(jù)也不用擔(dān)心用例之間互相污染。第三是網(wǎng)絡(luò)層可控。我可以直接攔截接口請(qǐng)求mock 一個(gè)返回值或者檢查某個(gè)請(qǐng)求有沒有發(fā)出去。這在處理第三方登錄、支付回調(diào)、異常場(chǎng)景時(shí)幾乎是救命級(jí)的功能。Selenium 要做同樣的事通常要起一個(gè)代理服務(wù)復(fù)雜度完全不是一個(gè)量級(jí)。2.3 遷移成本有多高一次真實(shí)的替換記錄我遷移的規(guī)模大概是一百多條用例花了一個(gè)周的時(shí)間。核心工作其實(shí)就是三塊選擇器從 XPath 改成>import { defineConfig, devices } from playwright/test; export default defineConfig({ testDir: ./tests/e2e, timeout: 30_000, fullyParallel: true, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 2 : undefined, reporter: [ [list], [html, { outputFolder: playwright-report }], ], use: { baseURL: process.env.E2E_BASE_URL || http://localhost:3000, trace: on-first-retry, screenshot: only-on-failure, video: retain-on-failure, }, projects: [ { name: chromium, use: { ...devices[Desktop Chrome] } }, { name: firefox, use: { ...devices[Desktop Firefox] } }, { name: webkit, use: { ...devices[Desktop Safari] } }, { name: mobile-chrome, use: { ...devices[Pixel 5] }, }, ], });這份配置里我想強(qiáng)調(diào)幾個(gè)關(guān)鍵點(diǎn)。fullyParallel: true意味著每條用例可以獨(dú)立并行跑前提是你的系統(tǒng)沒有共享數(shù)據(jù)庫(kù)狀態(tài)。如果用例之間有強(qiáng)依賴就千萬(wàn)別開全并行否則你會(huì)被隨機(jī)失敗折磨到崩潰。retries我特意只在 CI 里配置本地不開重試因?yàn)楸镜厥?yīng)該立刻看到好方便斷點(diǎn)調(diào)試。use里的trace配置為on-first-retry意思是首次失敗后自動(dòng)錄制一次完整 trace。這個(gè)功能是 Playwright 最值錢的特性之一——它能記錄頁(yè)面每一步的 DOM 快照、網(wǎng)絡(luò)請(qǐng)求、控制臺(tái)日志甚至可以像回放視頻一樣回看整個(gè)執(zhí)行過(guò)程。排查 flaky 用例時(shí)打開 trace 文件基本十分鐘內(nèi)就能定位問題。3.3 數(shù)據(jù) profile測(cè)試環(huán)境的隔離設(shè)計(jì)很多 E2E 套件頻頻報(bào)紅根因根本不是代碼 bug而是測(cè)試數(shù)據(jù)和環(huán)境串了。典型場(chǎng)景A 用例用賬號(hào)buyertest.com下單B 用例也用同一個(gè)賬號(hào)兩邊同時(shí)跑互相把購(gòu)物車數(shù)據(jù)頂?shù)粲谑莵y成一團(tuán)。我的解決方案是給每套 profile 配獨(dú)立的測(cè)試賬號(hào)和數(shù)據(jù)集合。具體做法分幾步每個(gè) profile 定義一組專屬賬號(hào)前綴比如e2e-chromium-buyerdomain.com、e2e-firefox-admindomain.com。用例初始化時(shí)通過(guò) API 創(chuàng)建獨(dú)立的數(shù)據(jù)快照比如每跑一輪就生成一個(gè)隨機(jī)訂單號(hào)斷言時(shí)只查自己的訂單號(hào)。涉及數(shù)據(jù)庫(kù)寫操作的用例盡量用 API 直接造數(shù)據(jù)而不是靠 UI 填寫表單。因?yàn)?UI 造數(shù)據(jù)慢且不穩(wěn)API 造數(shù)據(jù)三十毫秒就能完成還能避免很多重復(fù)操作。這一塊是 E2E 穩(wěn)定性治理里最基礎(chǔ)也最重要的工作。我見過(guò)不少團(tuán)隊(duì)一直抱怨 E2E flaky結(jié)果檢查最后發(fā)現(xiàn)一半問題出在共用測(cè)試數(shù)據(jù)上。4. 手寫一個(gè)能過(guò)審的真實(shí)用例登錄、下單、斷言的完整鏈路4.1 選對(duì)選擇器data-testid 的約定無(wú)論用什么框架選擇器策略都是 E2E 用例長(zhǎng)期可維護(hù)性的分水嶺。我最推薦的做法是前端代碼為每個(gè)關(guān)鍵交互元素加>import { test, expect } from playwright/test; test(普通用戶完成下單全流程, async ({ page }) { await page.goto(/products); await page.getByTestId(product-card-1001).click(); await page.getByTestId(add-to-cart).click(); const cartBadge page.getByTestId(cart-badge); await expect(cartBadge).toHaveText(1); await page.goto(/checkout); await page.getByTestId(email-input).fill(e2e-chromium-buyerdomain.com); await page.getByTestId(submit-order).click(); const orderSuccess page.getByTestId(order-success); await expect(orderSuccess).toBeVisible(); await expect(orderSuccess).toContainText(下單成功); await page.waitForResponse((resp) resp.url().includes(/api/orders) resp.status() 200, ); });這里的關(guān)鍵細(xì)節(jié)是先點(diǎn)擊下單再通過(guò)waitForResponse等接口返回這樣我們可以同時(shí)驗(yàn)證 UI 狀態(tài)和接口行為。斷言關(guān)閉前再用.toHaveText或者.toContainText做一次精確校驗(yàn)避免出現(xiàn)“頁(yè)面顯示成功但其實(shí)是舊數(shù)據(jù)殘留”的假陽(yáng)性。4.3 斷言寫什么才不算白寫E2E 用例的斷言設(shè)計(jì)也是一門小學(xué)問。最基礎(chǔ)的斷言是“元素可見”但只斷可見性往往兜不住真正的問題。舉個(gè)例子你點(diǎn)了一個(gè)提交訂單按鈕如果服務(wù)端返回 500前端可能彈出一個(gè)紅色錯(cuò)誤提示也可能依然顯示“下單成功”的占位頁(yè)面——這取決于代碼的質(zhì)量。因此E2E 斷言不要只寫“頁(yè)面跳轉(zhuǎn)了”最好斷言用戶真正關(guān)心的結(jié)果訂單詳情頁(yè)出現(xiàn)訂單號(hào)、金額、收貨地址這三項(xiàng)信息并且與下單時(shí)填寫的數(shù)值一致。我自己的習(xí)慣是對(duì)于寫操作類用例斷言三層——UI 上有明確成功反饋、接口調(diào)用返回預(yù)期狀態(tài)碼、數(shù)據(jù)在 UI 回顯無(wú)誤。這三層都通過(guò)這條用例才有資格算真正守住了路徑。5. 把 e2e 塞進(jìn) CI分片執(zhí)行、失敗重試與穩(wěn)定性治理5.1 GitHub Actions 里的最小可用配置本地跑通了只是第一步E2E 真正的價(jià)值在于每次代碼變更后自動(dòng)兜底。我目前最常用的一套 CI 配置長(zhǎng)這樣name: e2e-tests on: pull_request: branches: [main] push: branches: [main] jobs: e2e: runs-on: ubuntu-latest strategy: matrix: shard: [1, 2, 3] steps: - uses: actions/checkoutv4 - uses: actions/setup-nodev4 with: node-version: 20 - run: npm ci - run: npx playwright install --with-deps chromium - name: Run E2E tests run: npx playwright test --shard${{ matrix.shard }}/3 env: E2E_BASE_URL: ${{ secrets.E2E_STAGING_URL }} - uses: actions/upload-artifactv4 if: failure() with: name: playwright-report-shard-${{ matrix.shard }} path: playwright-report/ retention-days: 7這份配置里有幾個(gè)細(xì)節(jié)是多次踩坑后的結(jié)論。npx playwright install --with-deps chromium這句里的--with-deps會(huì)幫你把瀏覽器運(yùn)行所需的系統(tǒng)庫(kù)一并裝上省得在 CI 環(huán)境里缺依賴報(bào)錯(cuò)。shard 矩陣把測(cè)試分到三臺(tái)機(jī)器上跑大幅縮短整體用時(shí)。失敗時(shí)上傳報(bào)告這樣就算 CI 紅了你也不用去翻構(gòu)建日志的茫茫大海。5.2 分片與并行把半小時(shí)的套件壓到 5 分鐘E2E 一大痛點(diǎn)就是跑得慢。在僅有 60 條用例的情況下單機(jī)串行可能要 30 分鐘。我的做法是兩層并行本地層用 Playwright 自身的workers并行數(shù)CI 層用--shard把整個(gè)測(cè)試集再按機(jī)器切分。要特別注意并行帶來(lái)的資源問題每個(gè) worker 都會(huì)開一個(gè)新的瀏覽器實(shí)例內(nèi)存消耗不小。如果你的 CI 機(jī)器只有 2 核 4G強(qiáng)行開 4 個(gè) worker只會(huì)出現(xiàn)進(jìn)程被殺、瀏覽器啟動(dòng)失敗這種幺蛾子。保守的策略是先按機(jī)器的核心數(shù)配置 worker再用量化的shard數(shù)控制并行度。5.3 flaky test 治理先分類再動(dòng)手E2E 項(xiàng)目到后期最大的敵人已經(jīng)不是寫用例而是flaky test——今天跑紅重跑一遍就綠讓人懷疑人生。治理 flaky我有一套固定的排查順序第一類是環(huán)境問題服務(wù)端還沒起來(lái)、數(shù)據(jù)庫(kù)沒初始化、第三方依賴不穩(wěn)定。這類問題通常表現(xiàn)為“全片大面積紅”重跑一次就好了。解決辦法是加健康檢查真正的 E2E 前置校驗(yàn)要確認(rèn)應(yīng)用和服務(wù)都可訪問而不是直接開跑。第二類是時(shí)序問題某個(gè)異步請(qǐng)求比預(yù)期慢導(dǎo)致斷言時(shí)數(shù)據(jù)還沒渲染出來(lái)。解法是找到真正的觸發(fā)信號(hào)比如waitForResponse或者把斷言換成.toBeVisible這種自帶重試的寫法。第三類是數(shù)據(jù)沖突問題兩條并行用例污染了同一條數(shù)據(jù)。解法是回到 3.3 里的數(shù)據(jù)隔離方案去查把測(cè)試賬號(hào)和工廠數(shù)據(jù)徹底隔離開。第四類是選擇器脆弱問題UI 文案改變導(dǎo)致getByText失效。這類問題最好的解法是全面推行>