有些畫面元素,測試寫下去就是找不到、點不到,錯誤訊息還看不出所以然。十之八九是這幾個傢伙:瀏覽器原生彈窗、iframe、另開的新分頁,外加日期選擇器。這篇讓你認得它們的長相,然後親手打造一個練習沙盒——先產生受測程式,再產生測試程式,一類一類拆開講,最後對照一份業界常見場景的檢核表,確認該測的都測了、不測的說得出為什麼。認得,就能描述;能描述,AI 就能處理。
四個傢伙,各自為什麼難
圖 1:四種特殊元素的難點與處理概念
它們的共通點:都不在「目前這個網頁」的正常範圍裡。
• 原生彈窗(alert、confirm、prompt):是瀏覽器畫的,不是網頁內容。一般定位摸不到它。還有個陷阱:沒先掛好處理器,Playwright 預設會自動把它關掉,測試看起來有跑,其實什麼都沒確認。
• iframe:網頁裡嵌的另一個網頁,像頁中頁。直接找,會撲空。
• 新分頁:點擊後另開一頁,操作對象換了,你的測試還盯著舊分頁。
• 日期選擇器:跳出來的小月曆,本質上是幾十顆按鈕。訂房、報表查詢、保單生效日,到處都是它。
理解到這個層次就夠了。處理的技術細節是 AI 的事。
你的武器:描述現象
你不需要知道 frameLocator 或 dialog handler 這些詞。就像報 bug 不需要知道根因,把現象講清楚就是專業。
「點刪除會跳出瀏覽器那種灰灰的確認視窗」——AI 立刻知道是原生 confirm。「付款表單看起來是嵌進來的,跟頁面其他部分不太一樣」——它會去試 iframe。這篇接下來的做法,就是把這套「描述現象」的溝通,完整走一遍:先請 AI 產生受測程式,再請 AI 對它寫測試。
第一步:先產生受測程式
與其拿公司系統當第一次練習的對象,不如先蓋一個把四種元素都裝進去的最小沙盒。壞了就砍掉重來,沒有心理負擔。
圖 2:受測程式的頁面結構與設計原則
你可以這樣對 Claude Code 說:
幫我做一個練習用的網頁,放在 demo 資料夾,只用 HTML 和原生 JavaScript,不要任何框架:
1. 訂單列表:每筆訂單有「刪除」(按下跳出瀏覽器原生的確認視窗)和「備註」(跳出原生的輸入視窗);另有「全部匯出」按鈕,按完跳出原生的訊息視窗,訊息裡要有匯出筆數。
2. 意見回饋:一個文字框,填了字還沒送出就離開頁面,瀏覽器要跳出離開提醒。
3. 訂閱電子報:按「訂閱」打開一個網站風格的漂亮彈窗(不是瀏覽器原生的),裡面可以填 Email 送出;按 ESC 或點彈窗外的灰色遮罩可以關掉,點彈窗本體不能關。
4. 付款區:用 iframe 嵌入另一頁的付款表單,可以填卡號按付款;付款表單裡面再嵌一層 3D 驗證框,可以輸入簡訊驗證碼。
5. 月報表:一個會另開新分頁的連結,加一顆會彈出小視窗的按鈕。
6. 報表查詢:開始日、結束日兩個日期欄,外加一個點了會打開小月曆的生效日欄位,月曆要能切上下月。結束日早於開始日時,查詢要擋下並顯示錯誤訊息。
留意這段指令的寫法:通篇沒有出現 confirm、Modal、frameLocator 這些術語,只有「瀏覽器原生的」「網站風格的」「嵌入另一頁的」這類長相描述。這正是上一節說的武器。下面是產出來的四個檔案,放在 demo/ 資料夾。
demo/index.html
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<title>特殊元素練習沙盒</title>
<style>
body { font-family: sans-serif; max-width: 720px; margin: 24px auto; }
section { border: 1px solid #ccc; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
.msg { color: #2e7d32; }
.error { color: #c62828; }
/* 網頁 Modal(網站風格彈窗,非原生) */
#newsletter-modal { display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.4); align-items: center; justify-content: center; }
#newsletter-modal.open { display: flex; }
#newsletter-modal .panel { background: #fff; padding: 24px; border-radius: 12px; }
/* 自製月曆 */
#calendar { display: none; border: 1px solid #999; padding: 8px; width: 280px; }
#calendar.open { display: block; }
#cal-days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; margin-top: 8px; }
</style>
</head>
<body>
<h1>特殊元素練習沙盒</h1>
<!-- 區塊一:訂單列表(原生 confirm / prompt / alert) -->
<section aria-label="訂單列表">
<h2>訂單列表</h2>
<table id="orders">
<tr id="order-1001"><td>訂單 1001</td>
<td><button onclick="removeOrder('order-1001')">刪除</button>
<button onclick="addNote('order-1001')">備註</button></td></tr>
<tr id="order-1002"><td>訂單 1002</td>
<td><button onclick="removeOrder('order-1002')">刪除</button>
<button onclick="addNote('order-1002')">備註</button></td></tr>
</table>
<button onclick="exportAll()">全部匯出</button>
<p id="order-msg" class="msg"></p>
</section>
<!-- 區塊二:意見回饋(填一半離開觸發 beforeunload) -->
<section aria-label="意見回饋">
<h2>意見回饋</h2>
<textarea id="feedback" rows="3" cols="50"
placeholder="填到一半離開頁面,會跳出瀏覽器的離開提醒"></textarea>
</section>
<!-- 區塊三:訂閱電子報(網頁 Modal,對照組;可用 ESC 或點遮罩關閉) -->
<section aria-label="訂閱電子報">
<h2>訂閱電子報</h2>
<button onclick="openModal()">訂閱</button>
<div id="newsletter-modal">
<div class="panel">
<h3>訂閱電子報</h3>
<label>Email <input id="nl-email" type="email"></label>
<button onclick="subscribe()">送出</button>
<button onclick="closeModal()">關閉</button>
<p id="nl-msg" class="msg"></p>
</div>
</div>
</section>
<!-- 區塊四:付款(iframe 頁中頁,內含 3D 驗證的巢狀 iframe) -->
<section aria-label="付款">
<h2>付款</h2>
<iframe id="payment-frame" title="付款表單" src="payment.html"
width="480" height="260"></iframe>
</section>
<!-- 區塊五:月報表(新分頁連結+window.open 彈出視窗) -->
<section aria-label="月報表">
<h2>月報表</h2>
<a href="report.html" target="_blank" id="report-link">開啟月報表(新分頁)</a>
<button id="popup-report"
onclick="window.open('report.html', '_blank', 'width=600,height=400')">
彈出視窗看報表</button>
</section>
<!-- 區塊六:日期區間查詢(可直接填的日期欄+可跨月的自製月曆+區間檢查) -->
<section aria-label="日期查詢">
<h2>報表查詢</h2>
<label>開始日 <input id="start-date" type="date"></label>
<label>結束日 <input id="end-date" type="date"></label>
<label>生效日 <input id="effective-date" readonly onclick="toggleCalendar()"></label>
<div id="calendar">
<div>
<button id="cal-prev" onclick="moveMonth(-1)">上月</button>
<span id="cal-title"></span>
<button id="cal-next" onclick="moveMonth(1)">下月</button>
</div>
<div id="cal-days"></div>
</div>
<button onclick="runQuery()">查詢</button>
<p id="query-msg"></p>
</section>
<script>
// ---- 原生彈窗 ----
function removeOrder(id) {
if (confirm('確定要刪除這筆訂單嗎?')) {
document.getElementById(id).remove();
document.getElementById('order-msg').textContent = '已刪除';
}
}
function addNote(id) {
const note = prompt('請輸入備註:');
if (note) document.getElementById('order-msg').textContent = '備註已加入:' + note;
}
function exportAll() {
alert('匯出完成,共 ' + document.querySelectorAll('#orders tr').length + ' 筆');
}
// 意見回饋填了字就掛上離開提醒(beforeunload 也是原生彈窗的一種)
window.addEventListener('beforeunload', e => {
if (document.getElementById('feedback').value) {
e.preventDefault();
e.returnValue = '';
}
});
// ---- 網頁 Modal ----
const modal = document.getElementById('newsletter-modal');
function openModal() { modal.classList.add('open'); }
function closeModal() { modal.classList.remove('open'); }
function subscribe() {
document.getElementById('nl-msg').textContent = '訂閱成功';
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
});
modal.addEventListener('click', e => {
if (e.target === modal) closeModal(); // 點到遮罩本身才關,點面板不關
});
// ---- 日期查詢 ----
function runQuery() {
const s = document.getElementById('start-date').value;
const e = document.getElementById('end-date').value;
const msg = document.getElementById('query-msg');
if (s && e && e < s) {
msg.textContent = '結束日不可早於開始日';
msg.className = 'error';
return;
}
msg.textContent = '查詢完成';
msg.className = 'msg';
}
// 自製月曆:可跨月,但本質上仍然只是幾十顆按鈕
let calYear = 2026, calMonth = 8;
const pad = n => String(n).padStart(2, '0');
function renderCalendar() {
document.getElementById('cal-title').textContent = calYear + ' 年 ' + calMonth + ' 月';
const days = document.getElementById('cal-days');
days.innerHTML = '';
const total = new Date(calYear, calMonth, 0).getDate();
for (let d = 1; d <= total; d++) {
const b = document.createElement('button');
b.textContent = d;
b.onclick = () => {
document.getElementById('effective-date').value =
calYear + '-' + pad(calMonth) + '-' + pad(d);
document.getElementById('calendar').classList.remove('open');
};
days.appendChild(b);
}
}
function moveMonth(step) {
calMonth += step;
if (calMonth > 12) { calMonth = 1; calYear++; }
if (calMonth < 1) { calMonth = 12; calYear--; }
renderCalendar();
}
function toggleCalendar() {
document.getElementById('calendar').classList.toggle('open');
}
renderCalendar();
</script>
</body>
</html>
demo/payment.html(iframe 嵌入的那一頁)
<!DOCTYPE html>
<html lang="zh-Hant">
<head><meta charset="UTF-8"><title>付款表單</title></head>
<body>
<label>卡號 <input id="card-number"></label>
<button onclick="pay()">付款</button>
<p id="pay-msg"></p>
<!-- 巢狀 iframe:頁中頁裡再一層 3D 驗證 -->
<h3>3D 驗證</h3>
<iframe id="otp-frame" title="3D 驗證" src="otp.html"
width="360" height="90"></iframe>
<script>
function pay() {
const n = document.getElementById('card-number').value;
document.getElementById('pay-msg').textContent =
n.length === 16 ? '付款成功' : '卡號格式錯誤';
}
</script>
</body>
</html>
demo/otp.html(payment.html 裡再嵌一層的 3D 驗證)
<!DOCTYPE html>
<html lang="zh-Hant">
<head><meta charset="UTF-8"><title>3D 驗證</title></head>
<body>
<label>簡訊驗證碼 <input id="otp-code"></label>
<button onclick="verify()">驗證</button>
<p id="otp-msg"></p>
<script>
function verify() {
document.getElementById('otp-msg').textContent =
document.getElementById('otp-code').value === '123456'
? '驗證成功' : '驗證失敗';
}
</script>
</body>
</html>
demo/report.html(新分頁與彈出視窗開啟的那一頁)
<!DOCTYPE html>
<html lang="zh-Hant">
<head><meta charset="UTF-8"><title>月報表</title></head>
<body>
<h1>2026 年 7 月營收報表</h1>
<p id="total">總營收:1,234,567 元</p>
</body>
</html>
啟動方式:在專案根目錄跑 npx http-server demo -p 8080,然後開 http://localhost:8080。不要直接雙擊 HTML 檔用 file:// 開——瀏覽器對本機檔案的 iframe 有同源限制,會讓付款區塊的行為跟真實環境不一樣。
第二步:再產生測試程式,一類一類拆開講
沙盒蓋好了,換成測試視角。這一節照元素類型拆成五段,每段都是同一個節奏:先看怎麼用中文描述場景,再看 AI 產出的測試,最後一條一條講清楚每個 test 在防什麼——防的東西不一樣,合在一起講就都糊掉了。五段合計十七條測試,放在 tests/special-elements.spec.ts,檔頭這幾行是共用的:
import { test, expect } from '@playwright/test';
const BASE = 'http://localhost:8080';
test.beforeEach(async ({ page }) => {
await page.goto(`${BASE}/index.html`);
});
其一、原生彈窗:五個場景
你可以這樣對 Claude Code 說:
測試訂單列表和意見回饋:
場景 1:點「刪除」跳出瀏覽器原生的確認視窗,按確定 → 該筆訂單從列表消失,顯示「已刪除」。
場景 2:同上,按取消 → 訂單仍在列表上,畫面沒有出現任何刪除訊息。
場景 3:按「全部匯出」跳出原生的訊息視窗,要驗證訊息內容有寫到匯出完成。
場景 4:按「備註」跳出原生的輸入視窗,輸入文字後畫面要顯示該備註。
場景 5:意見回饋填了字還沒送出就離開頁面,瀏覽器要跳出離開提醒。
test.describe('原生彈窗', () => {
test('confirm 按確定:訂單消失並顯示已刪除', async ({ page }) => {
page.once('dialog', dialog => dialog.accept());
await page.locator('#order-1001').getByRole('button', { name: '刪除' }).click();
await expect(page.locator('#order-1001')).toHaveCount(0);
await expect(page.locator('#order-msg')).toHaveText('已刪除');
});
test('confirm 按取消:訂單仍在,也沒有出現刪除訊息', async ({ page }) => {
page.once('dialog', dialog => dialog.dismiss());
await page.locator('#order-1001').getByRole('button', { name: '刪除' }).click();
await expect(page.locator('#order-1001')).toBeVisible();
await expect(page.locator('#order-msg')).toHaveText('');
});
test('alert:確認匯出訊息內容', async ({ page }) => {
let message = '';
page.once('dialog', dialog => {
message = dialog.message();
dialog.accept();
});
await page.getByRole('button', { name: '全部匯出' }).click();
expect(message).toContain('匯出完成');
});
test('prompt:輸入備註文字', async ({ page }) => {
page.once('dialog', dialog => dialog.accept('急件'));
await page.locator('#order-1001').getByRole('button', { name: '備註' }).click();
await expect(page.locator('#order-msg')).toHaveText('備註已加入:急件');
});
test('beforeunload:表單填一半離開,要跳出離開提醒', async ({ page }) => {
await page.locator('#feedback').fill('打到一半的意見');
let dialogType = '';
page.once('dialog', dialog => {
dialogType = dialog.type();
dialog.accept();
});
await page.close({ runBeforeUnload: true });
expect(dialogType).toBe('beforeunload');
});
});
五條測試,五個不同的防守位置:
confirm 按確定—— 兩個斷言缺一不可:toHaveCount(0) 驗資料真的變了,toHaveText('已刪除') 驗使用者看得到回饋。只驗訊息,會被「訊息照顯示、資料沒刪掉」的 bug 騙過;只驗資料,則漏掉使用者按了卻沒有任何反應的問題。
confirm 按取消—— 最容易漏測的一條。兩個斷言的方向跟上一條剛好相反:訂單還在(toBeVisible)、訊息欄是空字串。第二個斷言常被省略——省略之後,「按取消也顯示已刪除」這種半吊子 bug 就溜過去了。而取消了卻照樣刪掉,是會上新聞的那種 bug。想到要測取消,是你的事,不是 AI 的事。
alert 訊息內容—— 這條的重點在 dialog.message()。很多人測 alert 只寫 accept() 把視窗關掉,測試綠了,但其實只確認了「有個視窗被關掉」——訊息寫錯字、筆數算錯,全都測不到。把訊息抓出來驗,才算測了 alert。
prompt 輸入文字—— accept('急件') 一次做兩件事:按下確定,同時把文字塞進輸入框。後面的斷言驗證這段文字真的進到系統、顯示在畫面上。這是三種原生彈窗裡唯一會「帶資料回來」的,所以驗證重點在資料有沒有被接住。
beforeunload 離開提醒—— 這條在教一個觀念:離開提醒也是 dialog 的一種,同一套 page.once('dialog') 就接得到,差別只在觸發方式——要用 page.close({ runBeforeUnload: true }) 主動離開頁面,再斷言 dialog.type() 是 'beforeunload'。另外,前置動作「先在欄位填字」不能省:沒填字就不會觸發,而「什麼情況下才該跳提醒」本身就是需求的一部分。
這一段的共同陷阱,值得單獨講:page.once('dialog', ...) 一律寫在點擊之前。原生彈窗是點下去的瞬間跳出來的,處理器晚一步掛上就接不到了。另一個要知道的預設行為:完全沒掛處理器時,Playwright 會自動把彈窗關掉(等同按取消)。所以「沒寫任何 dialog 處理,confirm 的測試卻全綠」不是好事,是警訊——它很可能一直在測按取消的路徑,而你以為在測按確定。
其二、網頁 Modal(對照組):三個場景
你可以這樣對 Claude Code 說:
測試訂閱電子報:按「訂閱」會打開網站風格的彈窗(不是瀏覽器原生的)。
場景 1:在彈窗裡填 Email 按送出 → 顯示「訂閱成功」。
場景 2:彈窗開著時按 ESC → 彈窗關閉。
場景 3:點彈窗外的灰色遮罩 → 彈窗關閉;但點彈窗本體 → 不能關。
test.describe('網頁 Modal(對照組:普通元素,不需特殊處理)', () => {
test('開啟 Modal、填表、送出', async ({ page }) => {
await page.getByRole('button', { name: '訂閱' }).click();
await page.locator('#nl-email').fill('reader@example.com');
await page.getByRole('button', { name: '送出' }).click();
await expect(page.locator('#nl-msg')).toHaveText('訂閱成功');
});
test('按 ESC 關閉 Modal', async ({ page }) => {
await page.getByRole('button', { name: '訂閱' }).click();
await expect(page.locator('#newsletter-modal')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.locator('#newsletter-modal')).toBeHidden();
});
test('點遮罩關閉 Modal(點面板不該關)', async ({ page }) => {
await page.getByRole('button', { name: '訂閱' }).click();
await page.locator('#newsletter-modal .panel').click();
await expect(page.locator('#newsletter-modal')).toBeVisible();
await page.locator('#newsletter-modal').click({ position: { x: 10, y: 10 } });
await expect(page.locator('#newsletter-modal')).toBeHidden();
});
});
開啟、填表、送出—— 整段程式碼跟測一個普通表單一模一樣,一行 dialog 處理都沒有。這正是把它放進沙盒的目的:親眼看到「網頁 Modal 不需要任何特殊技術」,以後就不會把兩種彈窗搞混、對著 Modal 掛 dialog 處理器然後納悶為什麼沒反應。
按 ESC 關閉—— 順序是:先斷言 Modal 打開了(toBeVisible),按 ESC,再斷言它關了(toBeHidden)。中間那個 toBeVisible 不是多餘的:少了它,萬一 Modal 根本沒打開,按 ESC 之後「本來就看不到」照樣讓測試通過——你以為測了 ESC,其實什麼都沒測。
點遮罩關閉—— 這條先點面板驗證「不會關」,再點遮罩驗證「會關」,正反兩面都測,才算把這個 UI 慣例測完整。點遮罩用了 position: { x: 10, y: 10 } 的座標點擊——平常該避免寫死座標,但這裡是合理的:遮罩鋪滿整個畫面、面板在正中間,點左上角必然落在遮罩上,而不是碰運氣。
其三、iframe:兩層都進得去
你可以這樣對 Claude Code 說:
測試付款:付款表單看起來是嵌進頁面的另一頁。
場景 1:填 16 碼卡號按付款 → 顯示「付款成功」。
場景 2:付款表單裡面還嵌了一層 3D 驗證框,輸入驗證碼 123456 按驗證 → 顯示「驗證成功」。
test.describe('iframe', () => {
test('在 iframe 裡填卡號並付款', async ({ page }) => {
const frame = page.frameLocator('#payment-frame');
await frame.locator('#card-number').fill('4242424242424242');
await frame.getByRole('button', { name: '付款' }).click();
await expect(frame.locator('#pay-msg')).toHaveText('付款成功');
});
test('巢狀 iframe:再進一層做 3D 驗證', async ({ page }) => {
const otp = page.frameLocator('#payment-frame').frameLocator('#otp-frame');
await otp.locator('#otp-code').fill('123456');
await otp.getByRole('button', { name: '驗證' }).click();
await expect(otp.locator('#otp-msg')).toHaveText('驗證成功');
});
});
在 iframe 裡填表—— frameLocator('#payment-frame') 的意思是「先進到那一頁,再開始找元素」。注意連最後的斷言都掛在 frame 底下——最常見的錯誤是進了 frame 填表,斷言卻寫回主頁面的 page 上,結果永遠找不到 #pay-msg,錯誤訊息還跟填表那幾行長得一樣,除錯除半天。原則一句話:進了哪一頁,就在哪一頁驗。
巢狀 iframe—— 頁中頁裡再一層,就把 frameLocator 再串一次,一層一個,像剝洋蔥。技術上只是多打一段,真正的關鍵在描述:跟 AI 講清楚層次關係——「付款表單裡面還有一個驗證框」——它就會照層次串;講成平行的兩個框,它就會串錯。
其四、新分頁與彈出視窗:三個場景
你可以這樣對 Claude Code 說:
測試月報表:
場景 1:點連結會另開新分頁,新分頁上要看得到總營收。
場景 2:如果重點只是報表內容,直接前往報表網址驗證就好。
場景 3:「彈出視窗看報表」按鈕開的是彈出小視窗,要跟新分頁一樣測得到內容。
test.describe('新分頁與彈出視窗', () => {
test('等新分頁事件,再到新分頁上斷言', async ({ page, context }) => {
const pagePromise = context.waitForEvent('page');
await page.locator('#report-link').click();
const reportPage = await pagePromise;
await reportPage.waitForLoadState();
await expect(reportPage.locator('#total')).toContainText('總營收');
});
test('替代做法:重點在內容時,直接前往報表網址', async ({ page }) => {
await page.goto(`${BASE}/report.html`);
await expect(page.locator('#total')).toContainText('1,234,567');
});
test('window.open 彈出視窗:處理方式與新分頁相同', async ({ page, context }) => {
const popupPromise = context.waitForEvent('page');
await page.locator('#popup-report').click();
const popup = await popupPromise;
await popup.waitForLoadState();
await expect(popup.locator('#total')).toContainText('總營收');
});
});
等新分頁事件—— 關鍵在順序:waitForEvent('page') 的 promise 要在點擊之前先建好,道理跟 dialog 處理器完全一樣——事件跑得比你的下一行程式碼快,先點再等,事件早就過去了。拿到新分頁之後還有一步 waitForLoadState():等它載入完再斷言,不然是在半張白紙上找元素,時好時壞的不穩定測試就是這樣來的。
直接前往網址的替代做法—— 跳過開分頁的動作,直接 goto 報表網址驗內容,更快也更穩。但要知道它的代價:它測不到「會不會另開分頁」這件事。所以兩條路怎麼選不是技術問題,是需求問題——後面「新分頁的一個決策點」專門講這個判斷。
window.open 彈出視窗—— 把這條跟場景 1 並排看:程式碼幾乎一個字不差。彈出視窗和新分頁在 Playwright 眼裡是同一種東西——context 裡多出來的一個 page。認得這一點,兩個看起來不同的畫面行為,其實就是同一招。
其五、日期選擇器:四個場景
你可以這樣對 Claude Code 說:
測試報表查詢:
場景 1:開始日、結束日直接填 2026-08-01 到 2026-08-15,查詢成功。
場景 2:生效日欄位點了會打開小月曆,點 15 日,欄位要變成 2026-08-15。
場景 3:月曆按兩次「下月」切到 2026 年 10 月,再點 15 日,欄位要是 2026-10-15。
場景 4:結束日填得比開始日早,查詢要被擋下並顯示錯誤訊息。
test.describe('日期選擇器', () => {
test('能直接填就直接填', async ({ page }) => {
await page.locator('#start-date').fill('2026-08-01');
await page.locator('#end-date').fill('2026-08-15');
await page.getByRole('button', { name: '查詢' }).click();
await expect(page.locator('#query-msg')).toHaveText('查詢完成');
});
test('不能直接填的,照點擊路徑走月曆', async ({ page }) => {
await page.locator('#effective-date').click();
await page.locator('#cal-days')
.getByRole('button', { name: '15', exact: true }).click();
await expect(page.locator('#effective-date')).toHaveValue('2026-08-15');
});
test('跨月選日期:切到 2026 年 10 月點 15 日', async ({ page }) => {
await page.locator('#effective-date').click();
await page.locator('#cal-next').click();
await page.locator('#cal-next').click();
await expect(page.locator('#cal-title')).toHaveText('2026 年 10 月');
await page.locator('#cal-days')
.getByRole('button', { name: '15', exact: true }).click();
await expect(page.locator('#effective-date')).toHaveValue('2026-10-15');
});
test('結束日早於開始日:系統要擋下', async ({ page }) => {
await page.locator('#start-date').fill('2026-08-15');
await page.locator('#end-date').fill('2026-08-01');
await page.getByRole('button', { name: '查詢' }).click();
await expect(page.locator('#query-msg')).toHaveText('結束日不可早於開始日');
});
});
能直接填就直接填—— 處理順序永遠從最簡單的開始:type=date 的欄位用 fill 塞 yyyy-mm-dd 就結束了,不用碰月曆。順帶回答一個常見疑問:那瀏覽器點了會跳出來的原生小月曆呢?不用點,也點不到——它跟原生彈窗是同一回事,瀏覽器畫的,不在網頁裡。fill 不是繞路,是正解。
照點擊路徑走月曆—— 自製月曆才需要一步一步點。exact: true 不能省:月曆裡同時有「1」和「15」,用包含比對會點錯顆——「數字按鈕一大堆」正是月曆定位偶爾要來回一兩次的主因。最快的偷吃步:用第 9 篇的 codegen 親手點一次,錄下來的就是正確路徑,交給 AI 整理進測試。
跨月選日期—— 留意中間那行對月份標題的斷言(toHaveText('2026 年 10 月'))。它讓測試失敗時直接告訴你死在哪一步:是「根本沒切到 10 月」,還是「切到了但點錯日子」。步驟一長,在關卡處放中途斷言,除錯時間差很多——這個習慣值得帶到所有多步驟的測試裡。
結束日早於開始日—— 前三條測的是「點得到」,這條測的是「擋得住」。不合理的日期區間該擋不擋,是業務規則,不是 UI 技術——也又是一個你比 AI 先想到的地方。不少人拿這條回去測自己系統,直接抓到 bug。
對照檢核:業界常見場景都考慮到了嗎
圖 3:業界常見測試場景覆蓋地圖
寫完測試最怕的不是寫錯,是漏掉。這張表把四類元素在實務上常見的測試場景攤開來對照。上一版有七個場景只標「文中提及」;這次把受測程式和測試補齊之後重新盤點,結論是:能補的全補進去了,十八個場景剩兩個——而且那兩個不是寫不出測試,是不該在這裡測。為什麼,下一節講。
剩下兩個,為什麼不直接補
跨網域第三方 iframe:限制在策略,不在工具
先澄清一個常見誤解:這不是 Playwright 做不到。Playwright 對跨網域 iframe 一樣能用 frameLocator 操作——這正是它比上一代工具強的地方之一,所以沙盒裡不需要特地架第二個網域來示範,招式跟同網域一模一樣。
真正的限制在策略面:第三方金流頁的內部不是你的受測物。對它寫斷言,等於把測試的命綁在別人的改版上——人家調個欄位名稱,你的測試全紅,而你的系統其實什麼都沒壞。更別說測試環境不該打真的金流。實務做法:用金流商提供的測試模式(沙盒卡號就是為此存在的),把驗證點放在流程回到自己網站之後——訂單狀態、付款紀錄、成功頁——而不是去斷言別人頁面裡的元素。能測,和該測,是兩回事。
時區與日期格式:限制在環境,不在場景
這個限制在環境層,多寫一條 UI 測試解決不了。典型災情長這樣:CI 主機設 UTC、你人在台北,程式裡 new Date() 接 toISOString() 之後,「今天」在 CI 上差了一天——測試在自己電腦全綠,一上 CI 就紅,查半天以為是元素定位問題。
處理位置有兩個,都不在測試場景清單裡:一是 CI 環境固定時區;二是 Playwright 設定檔裡用 use: { timezoneId: 'Asia/Taipei' } 固定測試瀏覽器的時區,讓本機和 CI 跑在同一個時間座標上。它該進的是團隊的環境檢查清單。這也是為什麼表上留著它:留在表上,你才會在排查「CI 才紅」的靈異現象時想起來。
這張表的用法不是逐條打勾交差,而是拿到你們系統上換句話問:我們的刪除功能,測過按取消嗎?我們的日期查詢,擋不擋不合理的區間?我們的第三方金流,驗證點放在自己這邊還是別人頁面裡?技術由 AI 補,場景的完整性由你把關。
網頁彈窗 vs 原生彈窗,先分清楚
一個常見的混淆:很多網站的確認視窗,其實是網頁自己做的漂亮版彈窗(Modal)。那是普通網頁元素,正常定位就找得到,不需要特殊處理——「其二」那三條測試已經示範過,零 dialog 程式碼。
判斷方法很土但有效:原生彈窗長得很陽春、樣式跟網站風格完全不搭、換個瀏覽器長相就變。跟 AI 描述時說清楚是哪一種——「網站風格的彈窗」或「瀏覽器那種陽春視窗」——它就不會用錯方法。
新分頁的一個決策點
點報表連結另開新分頁,技術上 AI 會用「等待新分頁事件」處理。但有個值得你介入的判斷:這個測試的重點是什麼?
重點是報表內容的話,走「替代做法」那條,直接前往報表網址測內容,跳過開分頁的動作,測試更快更穩。反過來,「在新分頁開啟」本身就是需求(怕蓋掉使用者填到一半的表單),那開分頁這件事就是必測點,走「等新分頁事件」那條。
同一個畫面行為,測不測、怎麼測,看需求意圖。這個判斷,自動化不會幫你做。
今天的練習
• 把受測程式蓋起來:照第一步的指令請 Claude Code 產生沙盒(或直接抄本文的四個檔案),用 npx http-server demo -p 8080 跑起來,親手把六個區塊各玩一遍。
• 把測試跑起來:npx playwright test,確認十七條全綠。然後故意把 index.html 裡取消刪除的邏輯改壞(confirm 不管按什麼都刪),看是哪一條測試抓到它——應該正是「按取消」那條。
• 回到你們系統:各找一個實例——原生彈窗、網頁 Modal、iframe、新分頁、日期選擇器。分不出前兩者的,用「換瀏覽器看長相變不變」判斷。挑一個,用描述現象的方式請 Claude Code 寫測試。
• 有日期查詢功能的話,加測一次「結束日早於開始日」,看系統擋不擋。另外把 timezoneId 設進 playwright.config.ts——現在用不到,CI 出現「只有它會紅」的靈異測試時,你會感謝這一步。
💡 撲空時的除錯口訣

圖 4:找不到元素時的三個檢查問題
測試說找不到元素,但你明明看得到它:先想三個——是不是在 iframe 裡?是不是在新分頁上?是不是原生彈窗?把口訣連同錯誤訊息丟給 Claude Code,通常一輪破案。