Playwright in the Development Loop — 開發循環的三個位置
Playwright 在前端開發循環的三個位置:假設驗證(寫 CSS 前)、行為驗證(規則寫完後)、互動驗證(dispatch event 後)。第 2 次同個版型 bug 出現 → 寫成測試固化。
適用:CSS / DOM debug、layout 驗收、互動行為驗證、寫 layout regression test。 不適用:純 unit test(function input/output、無 DOM)— 那用 Vitest / Jest 即可。
自包含聲明:閱讀本文件不需要先讀其他 reference。本文件涵蓋三個位置的具體 query 範例、layout test 模板、最低門檻 setup。
何時參閱本文件
| 訊號 | 該做的第一件事 |
|---|---|
| 即將寫 CSS 規則、想先確認 DOM 結構 | 位置 1:假設驗證 — 量 ancestor chain |
| 規則寫完、想確認實際 layout 對 | 位置 2:行為驗證 — 量 bounding rect |
| 想驗證使用者互動後的狀態(filter / search / click) | 位置 3:互動驗證 — dispatch event |
| 同個 layout bug 第 2 次出現 | 寫 layout test、CI 防回歸 |
| 不確定 server 怎麼起 / 怎麼接 playwright | 看下方「最低門檻 setup」 |
為什麼 playwright 是前端開發的核心驗證工具
CSS / DOM 的真實狀態 = 規則 + DOM tree + 樣式繼承 + 框架渲染的合成結果。靜態推理只能基於假設、視覺截圖只能傳達結果不傳達原因。
Playwright browser_evaluate 直接執行 JS 在 live page、返回 DOM tree / computed style / bounding rect — 把假設變成量測值。寫一個 evaluate fn ≈ 30 行 JS,比反覆推理快得多。
位置 1:假設驗證(寫 CSS 規則前)
量 ancestor chain
1async () => {
2 const el = document.querySelector('.target');
3 let chain = []; let n = el;
4 while (n && n !== document.body) {
5 chain.push(`${n.tagName}.${n.className}`);
6 n = n.parentElement;
7 }
8 return chain;
9}量子節點與 sibling
1async () => {
2 const parent = document.querySelector('.pagefind-ui');
3 return Array.from(parent.children).map(c => `${c.tagName}.${c.className}`);
4}量元素是否存在 / 數量
1async () => ({
2 count: document.querySelectorAll('.result').length,
3 first: document.querySelector('.result')?.outerHTML.slice(0, 200),
4})寫 CSS 規則前 30 秒能省掉後續 30 分鐘推理。
位置 2:行為驗證(規則寫完後)
量 bounding rect
1async () => ({
2 form: document.querySelector('.pagefind-ui__form').getBoundingClientRect(),
3 scope: document.querySelector('.scope').getBoundingClientRect(),
4 results: document.querySelector('.results').getBoundingClientRect(),
5})返回 {x, y, width, height, top, right, bottom, left} 的純物件、能直接 assert 順序與位置。
量 computed style
1async () => {
2 const el = document.querySelector('.target');
3 const cs = getComputedStyle(el);
4 return {
5 display: cs.display,
6 position: cs.position,
7 gridRow: cs.gridRow,
8 color: cs.color,
9 zIndex: cs.zIndex,
10 };
11}量「實際贏的 CSS rule」
1async () => {
2 const el = document.querySelector('.target');
3 // CSSOM 沒提供標準 getMatchedCSSRules;用 computed style 加 inspect
4 return getComputedStyle(el).cssText; // 全部 computed properties
5}或在 DevTools Computed panel 看 — 但 playwright 能寫成測試重跑。
位置 3:互動驗證(dispatch event 後讀 state)
模擬 input
1async () => {
2 const input = document.querySelector('.search-input');
3 input.value = 'pre';
4 input.dispatchEvent(new Event('input', { bubbles: true }));
5 await new Promise(r => setTimeout(r, 1000)); // 等 debounce / async render
6 return Array.from(document.querySelectorAll('.result'))
7 .filter(el => getComputedStyle(el).display !== 'none')
8 .map(el => el.textContent.slice(0, 50));
9}模擬 click
1async () => {
2 document.querySelector('.scope-toggle button[data-scope="title"]').click();
3 await new Promise(r => setTimeout(r, 500));
4 return {
5 activeScope: document.querySelector('.scope-toggle [aria-pressed="true"]')?.dataset.scope,
6 visibleResults: document.querySelectorAll('.result:not([hidden])').length,
7 };
8}模擬 viewport resize(透過 playwright API、不在 evaluate 內)
1await page.setViewportSize({ width: 375, height: 667 });
2const result = await page.evaluate(() => ({
3 layout: document.querySelector('.layout').getBoundingClientRect(),
4 sidebarVisible: getComputedStyle(document.querySelector('.sidebar')).display !== 'none',
5}));第 2 次同個 bug → 寫成 layout 測試固化
第 1 次 debug 完成後、bug 修好。第 2 次同個版型問題(不同 commit / viewport / 內容狀態)再出現 → debug 完後把 query 寫成 playwright 測試。
1import { test, expect } from '@playwright/test';
2
3test('search scope is between form and results', async ({ page }) => {
4 await page.goto('/search/?q=pre');
5 await page.waitForSelector('.result');
6
7 const formRect = await page.locator('.pagefind-ui__form').boundingBox();
8 const scopeRect = await page.locator('.scope-toggle').boundingBox();
9 const resultsRect = await page.locator('.results').boundingBox();
10
11 expect(scopeRect.y).toBeGreaterThan(formRect.y + formRect.height);
12 expect(resultsRect.y).toBeGreaterThan(scopeRect.y + scopeRect.height);
13});
14
15test('sidebar visible at 1400px+', async ({ page }) => {
16 await page.setViewportSize({ width: 1400, height: 800 });
17 await page.goto('/search/?q=pre');
18 await expect(page.locator('.sidebar')).toBeVisible();
19});
20
21test('sidebar hidden at < 1400px', async ({ page }) => {
22 await page.setViewportSize({ width: 1399, height: 800 });
23 await page.goto('/search/?q=pre');
24 await expect(page.locator('.sidebar')).toBeHidden();
25});未來 layout 改動觸發 regression、CI 立刻發現。
寫 layout test 的優先順序
不要每個 layout 都寫測試 — 寫測試的 ROI 條件:
| 條件 | 該寫測試嗎 |
|---|---|
| Bug 第 1 次出現 | 否(修了就好) |
| Bug 第 2 次出現 | 是(防回歸) |
| Layout 跟 viewport 強相關(breakpoint) | 是(容易壞) |
| Layout 跟 framework 重渲染相關 | 是(升級時需要驗證) |
| 純視覺風格(顏色 / 字型) | 否(用視覺 review 即可) |
最低門檻 setup
Server
1# 任何方式起本地 server
2hugo server # Hugo
3python3 -m http.server 8000 --directory public # 純靜態
4npm run dev # framework dev serverPlaywright MCP(給 Claude 用)
Claude 透過 MCP 提供的 tool:
browser_navigate(url)— 開頁browser_evaluate(fn)— 執行 JS 拿結果browser_take_screenshot()— 截圖browser_snapshot()— accessibility treebrowser_click(selector)/browser_type(selector, text)— 互動
Playwright 測試(給 CI 用)
1npm i -D @playwright/test
2npx playwright install
3npx playwright testplaywright.config.ts 設 baseURL 指向 http://localhost:1313(Hugo 預設)或自訂 port。
Wrong vs Right 對照
範例 1:CSS 不生效
錯:靜態推理 + 截圖溝通 4 次失敗。
對:第 2 次失敗 → 切 playwright:
1// 1. 確認 ancestor chain
2async () => {
3 const el = document.querySelector('.target');
4 let chain = []; let n = el;
5 while (n) { chain.push(`${n.tagName}.${n.className}`); n = n.parentElement; }
6 return chain;
7}
8// → 看到目標元素是 form 的 child、不是 .pagefind-ui 的直接 child
9
10// 2. 確認 computed style 誰贏
11async () => getComputedStyle(document.querySelector('.target')).color
12// → "rgb(0,0,255)" — vendor 的藍色贏了
13
14// 3. 換方向:用 @layer 把 vendor 包起來
範例 2:Layout 第 2 次出現一樣的 bug
錯:手動在不同 viewport 下視覺驗證、commit、過幾週又壞、又手動驗證。
對:第 2 次出現後寫成測試:
1test('layout golden path: form → scope → results', async ({ page }) => {
2 for (const width of [375, 768, 1024, 1400, 1920]) {
3 await page.setViewportSize({ width, height: 800 });
4 await page.goto('/search/?q=pre');
5 const form = await page.locator('.pagefind-ui__form').boundingBox();
6 const scope = await page.locator('.scope-toggle').boundingBox();
7 expect(scope.y, `at width=${width}`).toBeGreaterThanOrEqual(form.y + form.height);
8 }
9});未來改 CSS、CI 直接告訴你哪個 viewport 壞了。
RED-GREEN 順序:先看到 RED 才相信 GREEN
寫完 playwright test 後、必須先在「buggy code」跑出 RED 才能相信「fixed code」的 GREEN。詳見 #69 Test-First:先看到 RED 才相信 GREEN。
修 bug 的順序:
- 先寫測試 + 跑 → RED(在 buggy code 上 fail、證明測試會 catch + bug 真的存在)
- 修 code
- 跑測試 → GREEN(證明修對了 + 測試會抓回歸)
跳過 step 1 的 retrospective 補救(修完才補測試):
1# Stash 修復、checkout 修前 commit
2git stash && git checkout <pre-fix-commit>
3
4# Cherry-pick 測試 commit、build、跑
5git cherry-pick <test-commit>
6make site && npm test
7# 預期:RED
8
9# 切回修後版本
10git checkout main && git stash pop
11npm test
12# 預期:GREEN兩個訊號都看到 + 順序對、測試才被驗證。
自檢清單(dogfooding)
debug / 驗證 layout 時:
- 寫 CSS 規則前、有沒有用 playwright 量過 ancestor chain?
- 規則寫完後、有沒有用 playwright 量過 bounding rect / computed style 確認?
- 互動行為(filter / click)有沒有用 playwright 模擬 + 量化驗證?
- 同個 layout bug 第 2 次出現時、有沒有寫成測試?
- 推理失敗 ≥ 2 次時、有沒有主動切換到 playwright(不等到第 5 次)?
延伸閱讀
對應的事後檢討(在 content/report/):
- playwright-early-in-loop — 在開發循環裡早一點用 playwright 看真實結果
- layout-tests-with-playwright — 用前端測試把排版問題自動化
- verification-method-timing — 驗證方法的選擇時機
Last Updated: 2026-04-26 Version: 0.1.0