為什麼你的 Playwright 測試套件在超過 50 個測試後開始變得不穩定

Back
Category : News

每一個 Playwright 測試套件一開始都很乾淨。二十個測試全部綠燈,九十秒內跑完。然後它開始成長。

到了第五十個測試時,有人加入了 waitForTimeout 來「修復」競爭條件。到了第一百個測試時,測試套件大概每四次執行就會失敗一次——而且從來不是同一個測試失敗兩次。人們開始重新執行管線直到它變綠為止。到那個時候,測試套件已經不再是安全網,而是變成了稅負。

我看過這個現象在足夠多的專案上發生,注意到它幾乎總是由相同的五個原因造成。它們沒有一個是特別複雜的。它們如下,每一個都附上對應的修復方式。




1. waitForTimeout 不是等待,它是一場賭博

這是最大的一個。它在測試第一次出現間歇性失敗時就會出現,有人會伸手拿最快速的修復方式:

await page.click('#submit');
await page.waitForTimeout(2000);        // ← the bet
await expect(page.locator('.result')).toBeVisible();

Enter fullscreen mode

Exit fullscreen mode

這場賭博是假設兩秒鐘永遠都足夠。在你的筆電上是足夠的。在凌晨三點負載較重的 CI 執行器上有時就不夠了。而在那些確實足夠的日子裡,你仍然要付出兩秒鐘的代價——乘以一百個測試,你就為每次執行無謂地多增加了三分鐘。

修復方式:等待你真正需要的東西。

await page.click('#submit');
await expect(page.locator('.result')).toBeVisible();   // auto-waits, up to the timeout

Enter fullscreen mode

Exit fullscreen mode

Playwright 的斷言已經會自動重試直到通過或逾時。你幾乎不需要明確的等待。當你真的需要時——例如點擊觸發了一個 XHR,而你需要它的結果才能繼續——就等待那個特定的事情:

const response = page.waitForResponse(res => res.url().includes('/api/order'));
await page.click('#submit');
await response;

Enter fullscreen mode

Exit fullscreen mode

用 lint 來強制執行,而不是靠紀律。 紀律在截止期限壓力下會失效。加入 eslint-plugin-playwright 並將 no-wait-for-timeout 開啟為錯誤。現在管線會拒絕它,沒有人需要在審核時人工檢查。




2. 共用資料的測試在平行執行時會互相碰撞

這個很狡猾,因為它只有在你開啟平行執行後才會出現。

test('registers a new user', async ({ page }) => {
  await registerUser(page, '[email protected]', 'Password123!');
  await expect(page.locator('.welcome')).toBeVisible();
});

Enter fullscreen mode

Exit fullscreen mode

單獨執行時完美無缺。當你用兩個 worker 執行它,或是在沒有重置資料庫的情況下執行兩次,第二個就會碰到「email already registered」。這個失敗看起來像是註冊錯誤。其實不是。這是你的測試資料造成的。

修復方式:為每個測試產生唯一的資料,絕對不要硬編碼。

const user = {
  email: `qa.${randomUUID().split('-')[0]}@example.com`,
  password: 'Str0ng!Passw0rd',
};

Enter fullscreen mode

Exit fullscreen mode

寫一個小型的工廠模組是很值得的。而且——這聽起來過度,直到它幫你省了一天時間——為工廠本身撰寫單元測試。一個會默默產生重複資料的工廠會造成看起來完全像是應用程式錯誤的失敗,你會在錯誤的程式碼庫中花上好幾小時才懷疑到它。斷言「2000 個產生的 email 都是唯一的」只需要三十毫秒,並能消除這一整類的混亂。




3. 每個測試都重新登入一次

這不完全是不穩定,但它會讓其他問題更加惡化。如果每個測試一開始都要填寫登入表單,你就要為每個測試付出三到五秒,並給自己額外一百次機會在這個最常執行的流程上碰到計時失敗。

修復方式:登入一次,儲存 session,重複使用它。

建立一個設定專案:

// tests/auth.setup.ts
setup('authenticate', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('Username').fill(process.env.TEST_USERNAME!);
  await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
  await page.getByRole('button', { name: 'Login' }).click();

  await expect(page.getByRole('link', { name: 'Logout' })).toBeVisible();
  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});

Enter fullscreen mode

Exit fullscreen mode

將它設定為相依性:

projects: [
  { name: 'setup', testMatch: /.*.setup.ts/ },
  {
    name: 'chromium',
    use: { ...devices['Desktop Chrome'], storageState: 'playwright/.auth/user.json' },
    dependencies: ['setup'],
  },
]

Enter fullscreen mode

Exit fullscreen mode

在一個 200 個測試的套件中,這通常能為每次執行減少好幾分鐘。它通常是可取得的最大單一速度改善。

有一個細節很重要:讓設定專案大聲斷言登入確實成功。如果它默默失敗,每個下游測試都會以令人困惑的方式失敗,而你會去除錯錯誤的東西。




4. 綁定在標記而不是意義上的 Locator

await page.click('.btn-primary.submit-form > span:nth-child(2)');

Enter fullscreen mode

Exit fullscreen mode

這個今天能通過,但在下一次有開發者把東西包在 div 裡時就會壞掉。更糟糕的是,它會「默默」壞掉,因為失敗訊息完全沒有告訴你實際上發生了什麼改變。

修復方式:根據使用者感知的東西來定位。

await page.getByRole('button', { name: 'Submit order' }).click();

Enter fullscreen mode

Exit fullscreen mode

這個優先順序很穩固:

  1. getByRole —— 能承受重新設計,符合使用者和螢幕閱讀器看到的內容
  2. getByLabel —— 將欄位與其可存取標籤繫結
  3. getByTestId —— 當標記沒有提供任何語義資訊時
  4. CSS/XPath —— 最後手段

還有一個值得一提的額外好處:如果 getByRole 和 getByLabel 找不到你的元素,這通常代表應用程式中存在真正的無障礙問題。你的測試套件開始免費兼作無障礙冒煙測試。




5. 在所有東西上都失敗的視覺測試

團隊加入視覺回歸測試,在每個不相關的變更上都得到失敗,然後在一個月內就把它關掉。有三個原因:

動畫。 在轉場中間拍的截圖永遠不會符合。全局停用它們:

expect: {
  toHaveScreenshot: { animations: 'disabled', maxDiffPixelRatio: 0.02 },
}

Enter fullscreen mode

Exit fullscreen mode

不同機器上的字型渲染。 macOS 和 Linux 的抗鋸齒方式不同,所以在本機產生的基準永遠不會與 CI 相符。要嘛在 Docker 內產生基準,讓每個人都與相同的渲染比較;或者保留一個小的像素容忍度。

真正動態的內容。 時間戳、頭像、廣告欄位。遮罩它們:

await expect(page).toHaveScreenshot('dashboard.png', {
  mask: [page.locator('time'), page.locator('.ad-slot')],
});

Enter fullscreen mode

Exit fullscreen mode

並且優先選擇元件層級的快照,而不是全頁面的。全頁面基準在任何地方有任何改變時都會失敗。只有結帳表單的快照只會在結帳表單改變時失敗——這才是你真正想要的訊號。




這五個問題底層的模式

這五個問題的每一個都是同樣的錯誤,只是穿著不同的衣服:將關於時機或狀態的假設編碼進去,而不是等待真正的事情發生。

固定的延遲假設了時機。硬編碼的 email 假設沒有人同時在執行。CSS 路徑 locator 假設了 DOM 的形狀。全頁面快照假設頁面上沒有其他東西會改變。

用實際的條件取代每個假設,不穩定就會消失。不是「大部分消失」——而是完全消失。不穩定的測試套件並不是瀏覽器測試的固有特性,它們是建立在假設上的測試套件的特性。




一個實際的修復順序

如果你繼承了一個不穩定的測試套件,不要重寫它。依照這個順序進行——先做最便宜且影響最大的:

  1. 加入 lint 規則 讓它幫你找出每一個 waitForTimeout
  2. 加入認證快取 —— 最大的速度改善,只碰一個檔案
  3. 修復前三個最不穩定的測試 —— 你的 CI 歷史知道它們是哪些
  4. 在你碰到每個檔案時替換脆弱的 locator,而不是一次大掃除
  5. 當你下次碰到碰撞時加入資料產生機制

你通常從前兩項就能得到大部分的好處。


我把這些模式——加上 API 測試、CI 分片和 Docker 設定——打包成一個 Playwright 入門套件,因為我每次專案都在重複建立同樣的東西。但這篇文章裡的所有內容都可以獨立使用;它們都不需要購買任何東西。

https://dev.to/shaban_umar_eb956a5f684ce/why-your-playwright-suite-gets-flaky-after-50-tests-and-how-to-fix-it-1b44

https://www.worldprogramming.org/posts/why-your-playwright-suite-gets-flaky-after-50-tests-and-how-to-fix-it-g2dtgv