iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
Claude AI

AI 時代下最值得投資的 UI 自動化:30 天用 Claude Code 學會寫 Playwright系列 第 19

Day19: 截圖與視覺比對:畫面長歪了怎麼抓

  • 分享至 

  • xImage
  •  

toHaveText 全部通過,使用者看到的卻是文字疊成一團的災難頁。功能性斷言有個天生盲區:它驗內容對不對,不驗長得對不對。視覺比對補的就是這個洞。但先講醜話:它是維護成本最高的測試手法,用錯地方會反噬。

這一篇跟前面不太一樣:我們先動手蓋一個「一半穩定、一半動態」的受測程式,把業界最常撞到的動態內容問題全部埋進去,然後一個場景一個場景拆解。測試程式不用手寫——每個場景都示範怎麼對 Claude Code 下 prompt、它產出什麼、以及人要在哪裡把關。你會發現關鍵從來不在 API 怎麼寫,而在你給的情報夠不夠準。

原理:跟基準圖找碴
https://ithelp.ithome.com.tw/upload/images/20260818/20161809YSFtjkr7n1.png
圖 1:視覺比對的運作方式

機制很直觀。第一次執行時,把畫面截圖存成「基準圖」;之後每次執行,截新圖跟基準比,像素差異超過門檻就失敗。失敗時報告會並列三張圖:基準、現況、差異,紅色區塊就是不一樣的地方。

寫法只要一行 expect(page).toHaveScreenshot(),AI 秒懂。它抓得到的東西,恰好都是功能斷言的盲區:跑版、破圖、樣式沒載入、元素被遮住。

雙面刃的另一面:維護地獄
https://ithelp.ithome.com.tw/upload/images/20260818/201618097YFKUMjrx0.png
圖 2:選戰場——適用與不適用的場景

問題出在「像素差異」的判定太老實。改個文案、換張圖、日期從 8 月變 9 月,通通算差異、通通失敗。

放在天天改版的頁面上,視覺比對會天天紅。更新基準圖淪為例行公事,沒人再細看差異內容。警報器天天響,等於沒有警報器——第 7 篇講過的狼來了效應,在這裡發作得最快。

所以選戰場是成敗關鍵。穩定不常動的頁面(官網、報表版面、設計系統元件)是好戰場;有輪播、廣告、即時資料的頁面是災區。但真實世界大多數頁面落在中間地帶:版面穩定、內容會動。這塊怎麼處理,就是本篇的重點。

動手前:先蓋一個會動的受測程式

要練視覺比對,不能拿一個純靜態頁面練——那樣所有測試都會一次過,你永遠學不到怎麼對付動態內容。練習環境本身也用 prompt 產,重點是把「要埋哪些地雷」講清楚:

對 Claude Code 說

在我現有的 Playwright 專案裡,加一個練視覺比對用的受測程式:Express 伺服器 + 兩個頁面,沿用專案既有的結構與語言慣例。
第一頁 login 是純靜態登入頁,不要有任何動態內容,當視覺比對的理想對象。
第二頁 orders 是訂單儀表板,側欄、表頭、分頁器要穩定,但請刻意埋三種業界常見的動態內容:(1) 每秒更新的時鐘,要用 Date 實作,之後才能被 page.clock 凍結;(2) CSS 無限循環的促銷跑馬燈;(3) 訂單表格,資料從 /api/orders 用 fetch 拿,API 每次回傳隨機資料,連筆數都要隨機(4~7 筆)。
每個動態內容都要留對應的「攔截點」:時鐘走 Date、資料走網路、動畫走 CSS,讓之後的測試有辦法各個擊破。
最後在 playwright.config 加上 webServer 設定,讓測試執行時自動啟動這個伺服器。

以本文的獨立練習專案為例,產出長這樣:

visual-testing-demo/
├── app/
│   ├── server.js            # Express 伺服器 + 隨機訂單 API
│   └── public/
│       ├── login.html       # 純靜態登入頁(好戰場)
│       └── orders.html      # 訂單儀表板(中間地帶)
├── tests/
│   └── visual.spec.js       # 五種場景的視覺比對測試
└── playwright.config.js     # 統一截圖參數

先講清楚:你的結構不會長一樣,而且這是對的
上面這棵樹是「在空資料夾執行」的產物。你在自己的系列專案裡下同一個 prompt,AI 會沿用你既有的結構——config 在根目錄、tests/ 已經有其他測試、副檔名可能是 .ts——受測程式可能被放進 app/、src/demo/ 或其他位置。

這正是 prompt 第一句「沿用專案既有的結構與語言慣例」的作用。AI 產出的路徑每次、每個專案都可能不同,把某一次的產出當成標準答案去核對,是 prompt 協作最常見的誤會。

驗收看的不是路徑,是角色:受測程式(伺服器 + 兩頁)、測試檔、設定檔三個角色齊了、webServer 接上了、npx playwright test 跑得起來,結構長哪樣都算對。

驗收重點:prompt 裡最重要的兩句,一句是「連筆數都要隨機」——這顆地雷會在場景二炸出全篇最有價值的一課;另一句是「每個動態內容都要留攔截點」——受測程式的每個「動」,都要留對應的「治」。如果 AI 把時鐘寫死成字串、或把訂單資料直接寫在 HTML 裡,動態的地雷就是假的,後面的場景全都練不到,要退回去要求重做。

伺服器:每次都給你不一樣的資料

// app/server.js(節錄)
app.get('/api/orders', (req, res) => {
  const count = 4 + Math.floor(Math.random() * 4); // 4~7 筆,連筆數都隨機
  const orders = Array.from({ length: count }, () => ({
    id: 'ORD-' + (10000 + Math.floor(Math.random() * 90000)),
    customer: NAMES[Math.floor(Math.random() * NAMES.length)],
    amount: 500 + Math.floor(Math.random() * 9500),
    status: STATUS[Math.floor(Math.random() * STATUS.length)],
  }));
  res.json({ orders, serverTime: new Date().toISOString() });
});

重點在 count 也是隨機的:不只內容變,連資料筆數都變。這是刻意的——真實系統的列表頁就是這樣,而它會踩出一個遮罩技法蓋不住的坑,待會場景二見分曉。

兩個受測頁面:一個乖、一個皮

登入頁(login.html):沒有隨機資料、沒有時間、沒有動畫,每次載入都長一模一樣。這是視覺比對的理想對象,拿來練最基本的全頁比對。
https://ithelp.ithome.com.tw/upload/images/20260818/201618091hXBxx8pl5.png
受測頁面一:純靜態登入頁

訂單儀表板(orders.html):側欄、表頭、分頁器是穩定的,但頁面裡埋了三顆地雷——業界動態內容的三大典型:
• 時鐘:用 setInterval 每秒更新,對應「畫面上有時間日期」的場景,考驗你會不會凍結時鐘。
• 促銷跑馬燈:CSS animation 無限循環,截圖瞬間位置永遠不同,考驗你會不會停用動畫。
• 訂單表格:fetch 打 API 拿隨機資料,筆數 4~7 筆浮動,考驗你會不會遮罩與固定資料。
https://ithelp.ithome.com.tw/upload/images/20260818/20161809xcNZn0ujJx.png
受測頁面二:訂單儀表板(圖為場景四固定資料後的穩定畫面)

三顆地雷各對應一種攔截手段:時鐘走的是 Date,Playwright 的 page.clock 可以凍結它;表格資料走網路,page.route 可以攔截它;動畫走 CSS,設定檔一個參數可以停用它。受測程式的每個「動」,都留了對應的「治」。

業界常見場景與處理方法
https://ithelp.ithome.com.tw/upload/images/20260818/20161809rHUCRCVnkn.png
圖 3:場景對照表——遇到什麼問題,用什麼方法

這五個場景不用手寫,全部用 prompt 讓 Claude Code 產。每個場景的結構都一樣:先看怎麼下 prompt,再看它產出什麼,最後講驗收重點——AI 產的視覺測試,哪幾個地方要人眼把關。本文列出的程式碼都是這樣產出後、在練習環境實跑驗證過的版本。

下 prompt 的關鍵在於:你要說的不是「幫我寫視覺測試」,而是把場景的動態特性講清楚。AI 對 toHaveScreenshot 的 API 熟得很,它缺的是你頁面的情報——哪裡會動、怎麼動、動了會不會改變版面。情報給得越準,產出的測試越不會在第二次執行就紅給你看。

場景一:穩定靜態頁 → 直接全頁比對

對 Claude Code 說

幫 /login.html 加一條視覺比對測試。這頁是純靜態的,沒有動態內容,請做全頁截圖比對。基準圖第一次執行時會發生什麼事,請在程式註解裡說明。

它產出的測試:

test('場景1|登入頁:全頁視覺比對', async ({ page }) => {
  await page.goto('/login.html');
  await expect(page).toHaveScreenshot('login-full.png', { fullPage: true });
});

最簡單的形態,一行斷言,AI 幾乎不會寫錯。fullPage: true 會捲動截取整頁,而不是只截可視範圍。第一次執行時 Playwright 發現沒有基準圖,會自動存一張並把測試標為失敗——這是設計好的行為,提醒你「基準圖是這次產生的,請肉眼確認它長得對」。確認沒問題後,第二次執行起就是正常比對。

驗收重點:AI 常順手幫截圖檔取自動檔名(省略第一個參數)。要求它明確命名,之後在 snapshots 資料夾裡才找得到誰是誰。

場景二:動態列表頁 → 遮罩(mask)動態區塊

版面穩定、內容會動的頁面,業界最常用的折衷是遮罩:比對時把動態區塊蓋上色塊,基準圖與新圖蓋同樣位置,那塊區域內容再怎麼變都不影響結果。這個場景我們故意走兩輪 prompt,把踩雷的過程完整走一遍。

第一輪 prompt(會踩雷的版本)

幫 /orders.html 加視覺比對測試。頁面上有三個動態區:#order-body 是隨機訂單資料、#clock 每秒更新、.banner 是行銷跑馬燈。請遮罩這三塊,只比對整體版面(側欄、表頭、分頁器的位置與樣式)。

這個 prompt 把三個動態區都講清楚了,AI 產出的 mask 寫法也完全正確。第一次執行產基準圖、第二次執行——紅燈。

https://ithelp.ithome.com.tw/upload/images/20260818/201618091tFCxQK0YF.png
第一輪產出的測試,第二次執行的差異圖:紅色區塊顯示表格高度改變、分頁器位移

踩雷實錄:遮罩蓋不住的東西
原因:訂單筆數是隨機的(4~7 筆),表格高度跟著變,遮罩區塊「下方」的分頁器整個位移。遮罩處理的是內容差異,但版面高度變動是遮罩範圍以外的差異,照樣抓包。

第一輪 prompt 的盲點:我們講了「哪裡會動」,沒講「動了會不會改變版面」。AI 不知道筆數會浮動,它產的測試自然守不住。

第二輪 prompt(把坑講進去)
剛才的測試在第二次執行失敗了,差異圖顯示分頁器位移,原因是訂單筆數隨機(4~7 筆)造成表格高度變動。請修正:用 route 攔截 /api/orders,把筆數固定為 4 筆,但內容保持真實回應的資料(反正會被遮罩蓋掉)。遮罩的部分維持原樣。

它修正後的測試(實跑驗證版):

test('場景2|訂單頁:遮罩動態區塊後比對整體版面', async ({ page }) => {
  // 遮罩蓋得住「內容變動」,蓋不住「高度變動」——
  // 筆數用 route 固定住,內容仍是真實隨機資料(反正會被遮掉)
  await page.route('**/api/orders', async route => {
    const res = await route.fetch();          // 放行到真實 API
    const body = await res.json();
    body.orders = body.orders.slice(0, 4);    // 只固定「筆數」
    while (body.orders.length < 4) body.orders.push(body.orders[0]);
    await route.fulfill({ json: body });
  });
  await page.goto('/orders.html');
  await expect(page.locator('#order-body tr').first())
    .not.toContainText('載入中');
  await expect(page).toHaveScreenshot('orders-masked.png', {
    mask: [
      page.locator('#order-body'),  // 訂單資料:每次都是隨機的
      page.locator('#clock'),       // 時鐘:每秒都在變
      page.locator('.banner'),      // 跑馬燈文案:行銷內容常改
    ],
  });
});

https://ithelp.ithome.com.tw/upload/images/20260818/20161809UWL6THn3Jj.png
修正後的基準圖:粉紅色塊蓋住三個動態區,只比側欄、表頭、分頁器等穩定版面

驗收重點:一看比對前有沒有等待——那行 not.toContainText('載入中') 是先用功能斷言等資料渲染完再截圖,不等的話基準圖可能截到載入中的畫面;二看動態內容會不會改變元素尺寸,會的話光遮罩不夠。這兩點 AI 未必主動想到,是人要補的情報。

場景三:共用元件 → 元素級比對

對 Claude Code 說

我只想守住 .sidebar 這個共用側欄的外觀,不想比整頁——訂單頁其他區域常改,不要被波及。請寫元素級的視覺比對。

它產出的測試:

test('場景3|側欄元件:元素級比對', async ({ page }) => {
  await page.goto('/orders.html');
  await expect(page.locator('.sidebar')).toHaveScreenshot('sidebar-only.png');
});

把 toHaveScreenshot 掛在 locator 上而不是 page 上,就只截那個元素。範圍越小,受無關變動波及的機率越低,基準圖越不用更新。設計系統元件庫、共用側欄表頭,都適合這樣顧。訂單頁其他區域改得再兇,只要側欄沒動,這條測試永遠是綠的。

場景四:必須驗資料長相 → 把動態變因釘死

前面都在「避開」動態內容,但有些頁面的重點就是資料呈現:狀態標籤的配色對不對、金額有沒有千分位、表格行距有沒有跑掉。這時思路反過來——不是遮掉動態內容,而是讓動態內容變成固定的:

對 Claude Code 說

這次我要驗訂單資料「呈現的長相」:四種狀態標籤(已出貨/處理中/待付款/已取消)的配色、金額千分位格式、表格版面。請不要遮罩,改用 route 攔截 /api/orders 回傳寫死的測試資料,並把頁面時鐘凍結在固定時間,讓整頁完全確定性,然後全頁比對。測試資料請刻意涵蓋全部四種狀態。

它產出的測試:

const FIXED_ORDERS = {
  serverTime: '2026-08-15T09:00:00.000Z',
  orders: [
    { id: 'ORD-10001', customer: '測試客戶A', amount: 3200, status: '已出貨' },
    { id: 'ORD-10002', customer: '測試客戶B', amount: 980,  status: '處理中' },
    { id: 'ORD-10003', customer: '測試客戶C', amount: 5600, status: '待付款' },
    { id: 'ORD-10004', customer: '測試客戶D', amount: 1250, status: '已取消' },
  ],
};
 
test('場景4|訂單頁:固定資料 + 凍結時鐘後全頁比對', async ({ page }) => {
  await page.clock.setFixedTime(new Date('2026-08-15T09:00:00+08:00'));
  await page.route('**/api/orders', route =>
    route.fulfill({ json: FIXED_ORDERS }));
  await page.goto('/orders.html');
  await expect(page.locator('#order-body tr')).toHaveCount(4);
  await expect(page).toHaveScreenshot('orders-deterministic.png',
    { fullPage: true });
});

兩招各治一顆地雷:page.clock.setFixedTime 凍結頁面裡的時間,時鐘永遠顯示同一刻;page.route 攔截 API 回傳寫死的四筆資料,連四種狀態標籤各來一筆,一張截圖驗完所有配色。兩招下去,整頁每次都長一模一樣,連資料區都能放心全頁比。

驗收重點:「測試資料涵蓋全部四種狀態」這句是 prompt 裡最值錢的一句——一組資料涵蓋所有視覺變化,是設計測試資料的老手藝,AI 沒被要求時常只塞兩三筆同狀態的資料,配色漏驗了自己都不知道。另外檢查它有沒有用 toHaveCount 等資料渲染完才截圖。

場景五:RWD 斷點 → 一個頁面、多張基準圖

對 Claude Code 說

登入頁要驗三個 RWD 斷點:375(手機)、768(平板)、1280(桌機)。請用迴圈產生測試,基準圖檔名帶上寬度,讓失敗時一眼看出是哪個斷點跑版。

它產出的測試:

for (const width of [375, 768, 1280]) {
  test(`場景5|登入頁:寬度 ${width}px 的響應式比對`, async ({ page }) => {
    await page.setViewportSize({ width, height: 720 });
    await page.goto('/login.html');
    await expect(page).toHaveScreenshot(`login-w${width}.png`,
      { fullPage: true });
  });
}

用迴圈產生三條測試,檔名帶上寬度,手機、平板、桌機各存一張基準圖。哪個斷點跑版,測試名稱直接告訴你。這比手動拉瀏覽器寬度肉眼檢查三種版面,可靠得多也快得多。

設定檔:把地雷拆在源頭

對 Claude Code 說

視覺比對常見的誤報來源——CSS 動畫、輸入游標閃爍、字型反鋸齒——請在 playwright.config.js 統一處理,不要每條測試各寫各的。每個參數加註解說明它擋的是哪種誤報。

它產出的設定:

// playwright.config.js(節錄)
use: {
  viewport: { width: 1280, height: 720 }, // 視窗固定,基準圖才有意義
},
expect: {
  toHaveScreenshot: {
    animations: 'disabled',   // 截圖前停用 CSS 動畫/轉場
    caret: 'hide',            // 隱藏輸入游標(閃爍造成隨機差異)
    maxDiffPixelRatio: 0.001, // 允許 0.1% 像素差異,吸收反鋸齒雜訊
  },
},

三個參數各對付一種業界常見誤報,設在設定檔就全域生效,不用每條測試重複寫:

• animations: 'disabled'——受測頁的跑馬燈是無限循環動畫,截圖瞬間位置永遠不同。這個參數讓 Playwright 在截圖前把動畫快轉到結束狀態,跑馬燈直接消失在畫面外。這也是為什麼場景二還要遮罩 banner:動畫停了,但行銷文案本身還是會改。
• caret: 'hide'——如果測試曾點過輸入框,游標的閃爍會讓同一畫面兩次截圖不同,這是新手最常見的「明明沒改卻紅了」。
• maxDiffPixelRatio: 0.001——門檻。字型反鋸齒、次像素繪製會產生人眼看不見的細微差異,門檻太嚴會天天誤報;太鬆,小跑版又溜過去。從預設值出發,遇到誤報再微調,並讓 AI 解釋它調了什麼。

跨平台提醒:基準圖認作業系統
基準圖檔名會自動帶上平台後綴,例如 login-full-linux.png。同一頁面在 macOS 和 Linux 上的字型繪製不同,基準圖不通用。
業界慣例:以 CI 環境為準。基準圖一律在 CI(通常是 Docker 容器)裡產生與更新,本機只跑功能測試,或用官方 Docker image 在本機模擬 CI 環境產圖。
否則就會出現「我電腦上是綠的」這句 UI 測試版的經典台詞。

它跟人眼看的,差在哪

有個觀念先對齊:視覺比對不是「AI 看畫面覺得怪」,它是像素對像素的數學比較,比人眼嚴格得多,也笨得多。人眼會忽略的半像素位移,它算差異;人眼一秒看出的「這裡怪怪的但說不上來」,只要像素恰好一樣,它看不出。
所以門檻的本質是:用一個數字,劃出「機器該忽略哪些人眼也會忽略的差異」。它永遠劃不準,只能劃得夠用。

更新基準圖,是個嚴肅動作

比對失敗後,確認是合理的改版,就要更新基準圖,一個指令的事(npx playwright test --update-snapshots)。但請把這個動作當「修改測試的預期結果」來對待,因為它就是。

練習環境實測了一次完整流程:把登入按鈕從 teal 改成 amber,場景一立刻紅燈,差異圖精準圈出按鈕:

https://ithelp.ithome.com.tw/upload/images/20260818/20161809mXwdoXndKi.png
改版被抓到:差異圖把換色的按鈕整顆標紅,其餘區域一片乾淨

更新前必須肉眼看過差異圖——報告裡會並列基準、現況、差異三張——確認每處變化都是有意為之。這張圖如果只有按鈕紅,跟這次改版吻合,放心更新;如果紅色多了一塊沒人改過的區域,恭喜你,抓到一個沒人發現的跑版。不看就更新,等於把這次的畫面蓋章成新標準;這次剛好有個沒人發現的跑版,它從此就是「正確答案」了。

截圖的另一個身分:證據

跳出比對,截圖還有個樸素用法:在關鍵步驟主動截圖存檔,不比對、純留底。

對 Claude Code 說

在登入流程測試的關鍵步驟加「證據截圖」:只存檔、不做視覺比對,並把截圖附加到 HTML 報告裡,讓點開測試就看得到當時的畫面。

它產出的測試:

test('番外|關鍵步驟留證據截圖(不做比對)', async ({ page }, testInfo) => {
  await page.goto('/login.html');
  await page.fill('#email', 'demo@example.com');
  const evidence = testInfo.outputPath('evidence-login-filled.png');
  await page.screenshot({ path: evidence, fullPage: true });
  await testInfo.attach('登入頁填寫後',
    { path: evidence, contentType: 'image/png' });
  await expect(page.locator('#login-btn')).toBeEnabled();
});

page.screenshot() 只存檔不比對;testInfo.attach 把圖掛進 HTML 報告,點開測試就看得到。搭配第 10 篇的 trace,每次執行都留下完整的視覺紀錄。出事時是證據,平時是「自動化真的有在跑」的最直觀展示品。跟主管溝通自動化價值時,一疊截圖比一份通過率報表有感得多——第 25 篇的成果三件套,可以把它算一份。


上一篇
Day18: API 測試:Playwright 不是只會點畫面
系列文
AI 時代下最值得投資的 UI 自動化:30 天用 Claude Code 學會寫 Playwright19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言