「UI 自動化測試就像在沙灘上蓋城堡:潮水(頁面改版、網路延遲、非同步渲染)一來,看似堅固的腳本在一夜之間全崩了。」
大家好,我是 Jane,一個每天在 第一線處理 UI 自動化測試「忽真忽假(Flaky)」紅燈的 自動化測試工程師(SDET)。
恭喜大家跟著我完成了第二部分(Day 6~10)的測試框架架構搭建!現在我們手頭上已經有一套分層清晰的 pytest + Playwright 專案了。
但只要你開始把 UI 自動化測試放進 CI/CD 每日執行(Nightly Build),你很快就會面臨整個自動化旅程中最挫折的階段:
UI 自動化測試之所以被公認為「最脆弱(Brittle)的測試層級」,是因為它與系統的溝通隔著一層極度動態且複雜的 Web 前端。
今天這篇文章,我們就來徹底剖析:究竟是哪 8 大根源,讓我們的 UI 自動化測試這麼容易壞?
┌─────────────────────────┐
│ UI 測試脆弱的 8 大根源 │
└────────────┬────────────┘
│
┌───────────────────────────┼───────────────────────────┐
▼ ▼ ▼
【DOM 與前端動態性】 【網路與非同步時序】 【環境與狀態污染】
1. DOM 結構與 Class 頻繁改動 4. 非同步 API 與動畫延遲 6. 測試資料被共用與污染
2. 動態產生的 ID 與 CSS 5. 固定等待時間 (Magic Sleep) 7. 測試案例間順序依賴
3. 多層 Shadow DOM / iframe 8. 測試環境不穩定 (Flaky Infra)
現代 Web 框架(React/Vue/Tailwind)在編譯後,Class 名稱常變成 .css-1x2y3z 或 button-active__3kL2 這類雜湊值。當前端工程師重新 Build 專案,這些 Class 名稱就會全變,導致你的 CSS Selector 直接失效。
很多元件庫(如 Ant Design、MUI)會為輸入框生成動態 ID,例如 <input id=":r1:">。如果你的腳本寫死 page.locator("#:r1:"),只要頁面重新渲染,定位器就會出錯。
遇到第三方支付金流(如 Stripe/TapPay)或是 Web Components 時,元素會被包裹在 iframe 或 Shadow DOM 內部。如果沒有切換上下文(Context),Playwright 就無法在頂層 DOM 樹中找到該元素。
點擊「送出」按鈕後,前端會發送非同步 API 請求,並跳出 Loading Spinner。如果你的腳本在 API 尚未回傳、Spinner 還沒消失前就急著去拿結果,就會撞上 TimeoutError 或 Element is not interactable。
為了應付上述的非同步問題,許多 QA 會寫 time.sleep(5)。但網路一抖動,5 秒不夠用;網路快時,這 5 秒又造成資源浪費。固定等待是產生不穩定測試的第一大毒瘤!(Day 12 會專文解法)。
當多個測試案例共用同一個測試帳號時,案例 A 把密碼改了,案例 B 接著執行時就會登入失敗;或者平行執行(Parallel)時,兩個測試同時修改同一筆資料,造成競爭條件(Race Condition)。
test_b.py 假設 test_a.py 已經先執行過並建立了購物車。一旦 pytest 採用隨機順序(Randomize)或平行執行,test_b.py 就會立刻崩潰。
CI/CD Runner 的 CPU 與記憶體規格往往比 QA 的開發電腦差。在 local 順暢執行的瀏覽器,到了 CI Docker 容器裡因為 CPU 滿載而卡頓,引發一連串的 Timeout 失敗。
我們用一段典型的登入後搜尋使用者程式碼,看看脆弱的寫法與穩定的寫法差別有多大:
Python
# tests/test_brittle.py
import time
from playwright.sync_api import Page
def test_user_search_brittle(page: Page):
page.goto("https://admin.example.com/login")
# 脆弱點 1: 過度依賴層級結構與可能變動的 Class/ID
page.locator("div.login-box > form > div:nth-child(2) > input").fill("admin")
page.locator("#input-:r2:").fill("password123")
# 脆弱點 2: 使用硬編碼的固定等待
page.click("button.btn-primary")
time.sleep(3) # 如果網路慢了 0.1 秒,下面就會跳錯!
# 脆弱點 3: 斷言在不穩定的 DOM 上
page.fill("div.search-bar > input", "Jane")
page.click("button.search-icon")
time.sleep(2)
# 斷言:直接拿可能隨時被修改的 CSS 類別
assert page.locator(".user-list-item-name").inner_text() == "Jane"
Python
# tests/test_stable.py
from playwright.sync_api import Page, expect
def test_user_search_stable(page: Page):
page.goto("https://admin.example.com/login")
# 穩定點 1: 使用語意化與穩定的測試定位器 (data-testid)
page.get_by_test_id("username-input").fill("admin")
page.get_by_test_id("password-input").fill("password123")
page.get_by_test_id("login-submit-btn").click()
# 穩定點 2: 利用 Playwright 的自動等待 (Auto-waiting) 與條件式等待
# 等待頁面 URL 切換至 Dashboard,取代 time.sleep()
expect(page).to_have_url("https://admin.example.com/dashboard")
# 穩定點 3: 針對特定容器內的商業語意元件操作
search_input = page.get_by_test_id("user-search-input")
search_input.fill("Jane")
search_input.press("Enter")
# 穩定點 4: 使用 Web-first Assertions,內建自動重試機制直到斷言成立
user_row = page.get_by_test_id("user-table-row").filter(has_text="Jane")
expect(user_row).to_be_visible()
身為 Web UI QA,要打造「不敢輕易壞掉」的自動化測試,我們必須在寫腳本時養成以下 3 個習慣:
time.sleep() 賭網路速度。學習使用 Playwright 的 expect(locator).to_be_visible() 或 page.wait_for_response(),讓程式碼去「監聽系統狀態」,而不是「盲目等待」。data-testid 屬性,能直接避開 80% 的 DOM 改版災難。剖析了 UI 測試脆弱的 8 大根源後,明天開始我們將一個個打破它們!
首先要開刀的,就是上述提到的第 5 大根源:亂用固定等待時間。
明天 Day 12,我們將深入研討:《 Day 12|不要再亂用固定等待時間!顯式等待、條件式等待與 Timeout 實戰設計 》。