ก่อนอ่านบทนี้ ลองตอบ:¶
-
คุณมี test suite ที่ต้องรัน 150 tests ซึ่ง 120 tests ต้อง login ก่อน — คุณจะใช้ storageState อย่างไรเพื่อไม่ให้ login ซ้ำทุก test? และถ้า parallel tests บางตัว modify ข้อมูลของ user เดียวกัน จะมีปัญหาอะไร?
-
เพื่อนคุณ save storageState แล้ว commit ไฟล์
playwright/.auth/user.jsonขึ้น GitHub repository — มีความเสี่ยงอะไรบ้าง และควรแก้อย่างไร?
เฉลย:
-
สร้าง
auth.setup.tsที่ login ครั้งเดียวแล้ว save state — จากนั้น config ให้ test projects ใช้storageStateจากไฟล์นั้น ทุก test อ่าน state แทนการ login ใหม่ สำหรับ parallel tests ที่ modify ข้อมูล user เดียวกัน จะ conflict กันได้ ต้องใช้ per-worker auth (account แยกต่อ worker) เพื่อ isolation -
ความเสี่ยง: ไฟล์ JSON เก็บ cookies และ tokens จริง ใครที่ clone repo ได้ก็ impersonate account นั้นได้ทันที — แก้โดยเพิ่ม
playwright/.authใน.gitignoreและแจ้ง team ทันที
บทที่ 14: Advanced Browser Features & Emulation¶
1. วัตถุประสงค์¶
หลังอ่านบทนี้คุณจะ:
- จัดการ popup windows และ new tabs ด้วย
page.waitForEvent('popup')อย่างถูกต้อง - interact กับ content ภายใน iframe ผ่าน
frameLocator()และlocator.contentFrame() - handle dialogs (alert/confirm/prompt) โดย register handler ก่อน trigger action เสมอ
- upload และ download files ใน test โดยไม่ต้องเปิด OS dialog
- interact กับ Shadow DOM โดยที่ Playwright handle ให้อัตโนมัติ
- emulate mobile devices ด้วย
devices['iPhone 15 Pro']และ configure locale/timezone/geolocation - ใช้
page.emulateMedia()สำหรับ print stylesheet และ dark mode testing - ใช้
page.mouseและpage.keyboardสำหรับ low-level interactions ที่ locator ธรรมดาทำไม่ได้
2. ทำไมต้องรู้? (Why)¶
web app จริงในชีวิตจริงไม่ได้มีแค่ "click button แล้ว verify text" — มันซับซ้อนกว่านั้นมาก:
- Popup windows: เปิด OAuth login, payment gateway, หรือ preview ในหน้าต่างใหม่
- iFrames: embed แผนที่ Google Maps, video player, หรือ third-party widget
- Dialogs: browser-native
alert(),confirm(),prompt()ที่ JS code call โดยตรง - File operations: upload รูปโปรไฟล์, download report PDF
- Mobile testing: ทดสอบว่า responsive design พังหรือเปล่าใน iPhone
ใน Robot Framework + Selenium การทำสิ่งเหล่านี้ต้องการ keyword เฉพาะ (Select Window, Select Frame), library เพิ่มเติม (AlertKeywords), หรือ JavaScript workaround สำหรับ Shadow DOM Playwright built-in ทุกอย่างนี้ไว้ในภาษาเดียว
บทนี้จะให้คุณ control browser ได้เต็ม spectrum — ตั้งแต่ popup จนถึง mobile emulation
3. เนื้อหาหลัก¶
3.1 Popup Windows และ New Tabs¶
Popup คือ window หรือ tab ใหม่ที่เปิดจาก JavaScript (window.open()) หรือ link ที่มี target="_blank"
Pattern สำคัญ: ต้องเริ่ม listen ก่อน action ที่เปิด popup เสมอ ถ้า listen หลัง action อาจพลาด popup ที่เปิดเร็วมาก
// tested: Playwright v1.50+, Node.js 20+
// Pattern ที่ถูกต้อง — start listening before click
const popupPromise = page.waitForEvent('popup');
await page.getByText('open the popup').click();
const popup = await popupPromise;
// รอให้ popup load เสร็จก่อน interact
await popup.waitForLoadState();
await expect(popup).toHaveTitle(/Todo/);
await popup.close();
ถ้าต้องการ listen ทุก popup ที่เปิดตลอด test:
// tested: Playwright v1.50+, Node.js 20+
// Continuous listener สำหรับทุก popup
page.on('popup', async popup => {
await popup.waitForLoadState();
console.log(await popup.title());
});
สำหรับ Demo App — popup ที่ /advanced เปิดจาก button btn-open-popup:
// tested: Playwright v1.50+, Node.js 20+
test('popup opens and has correct title', async ({ page }) => {
await page.goto('/advanced');
const popupPromise = page.waitForEvent('popup');
await page.click('[data-testid="btn-open-popup"]');
const popup = await popupPromise;
await popup.waitForLoadState();
await expect(popup).toHaveTitle(/Todo/);
await popup.close();
});
3.2 iFrames กับ frameLocator¶
iframe (inline frame) คือหน้าต่างเล็กที่ embed อีก URL หนึ่งไว้ภายใน page เช่น widget แผนที่, video player, หรือ form payment ที่มาจาก domain อื่น demo app ของเรามี iframe ที่ /advanced ซึ่ง embed /todos page ไว้ข้างใน
frameLocator คือวิธีที่ Playwright แนะนำในการ interact กับ iframe — แทนที่จะต้อง "switch context" แบบ Selenium (Select Frame แล้ว Unselect Frame), frameLocator() return object ที่ scoped ภายใน iframe นั้นเลย ใช้เสร็จแล้วไม่ต้อง "unselect" กลับ
// tested: Playwright v1.50+, Node.js 20+
await page.goto('/advanced');
// ใช้ attribute selector เพื่อ target iframe ที่ต้องการ
const frame = page.frameLocator('iframe[data-testid="embedded-iframe"]');
// ทุก locator ที่ได้จาก frameLocator จะ scope ภายใน iframe นั้น
// iframe ใน demo app embed /todos page
await frame.getByTestId('input-new-todo').fill('Buy milk');
await frame.getByRole('button', { name: 'Add' }).click();
await expect(frame.getByText('Buy milk')).toBeVisible();
locator.contentFrame() (เพิ่มใน v1.43) — ใช้เมื่อคุณมี Locator ของ iframe element อยู่แล้ว แล้วต้องการ interact กับ content ข้างใน:
// tested: Playwright v1.50+, Node.js 20+
// มี locator ของ iframe element
const iframeLocator = page.locator('iframe[data-testid="embedded-iframe"]');
// แปลงจาก Locator → FrameLocator
const frameLocator = iframeLocator.contentFrame();
// ตอนนี้ interact กับ content ภายใน iframe ได้
await frameLocator.getByRole('button', { name: 'Add' }).click();
Operation ย้อนกลับ — จาก FrameLocator กลับเป็น Locator (element ของ iframe เอง):
// tested: Playwright v1.50+, Node.js 20+
const frameLocator = page.frameLocator('iframe[name="embedded"]');
const iframeElement = frameLocator.owner(); // กลับเป็น Locator ของ <iframe> element
⚠️ Cross-origin iframe: ถ้า iframe load content จาก domain อื่น (เช่น <iframe src="https://maps.google.com">) Playwright ไม่สามารถ inspect หรือ click element ข้างในได้ เพราะ browser same-origin policy — ทำได้แค่กับ same-origin iframe
3.3 Dialogs (alert/confirm/prompt)¶
Browser-native dialogs คือ window.alert(), window.confirm(), window.prompt() — เป็น blocking UI ที่หยุด JavaScript execution จนกว่าจะ dismiss
Rule #1: ลงทะเบียน handler ก่อน action ที่ trigger dialog เสมอ
// tested: Playwright v1.50+, Node.js 20+
// Alert — แค่ dismiss
page.once('dialog', async dialog => {
console.log(dialog.message()); // ข้อความใน dialog
await dialog.accept();
});
await page.click('[data-testid="btn-alert"]');
// Confirm — accept หรือ dismiss
page.once('dialog', async dialog => {
expect(dialog.type()).toBe('confirm');
await dialog.accept(); // คลิก OK
// await dialog.dismiss(); // คลิก Cancel
});
await page.click('[data-testid="btn-confirm"]');
// Prompt — ส่งค่าไปด้วย
page.once('dialog', async dialog => {
expect(dialog.type()).toBe('prompt');
await dialog.accept('my answer'); // type แล้วกด OK
});
await page.click('[data-testid="btn-prompt"]');
ความแตกต่างระหว่าง page.on() และ page.once():
- page.on('dialog', handler) — handle ทุก dialog ที่เกิดขึ้นตลอด lifecycle ของ page
- page.once('dialog', handler) — handle แค่ dialog ครั้งถัดไปครั้งเดียว (แนะนำสำหรับ test ที่รู้ว่า trigger ครั้งเดียว)
ถ้าไม่ register handler: Playwright จะ auto-dismiss dialogs ทั้งหมด — แต่ถ้า test รอผล dialog (เช่น confirm ส่งผล false → รอ delete ไม่เกิด) test จะพัง
3.4 File Upload¶
Playwright ใช้ setInputFiles() กับ <input type="file"> โดยตรง — ไม่ต้องเปิด OS file picker
// tested: Playwright v1.50+, Node.js 20+
// อัปโหลดไฟล์เดี่ยว
await page.locator('[data-testid="input-file"]').setInputFiles('path/to/file.txt');
// อัปโหลดหลายไฟล์พร้อมกัน
await page.locator('input[type="file"]').setInputFiles([
'tests/fixtures/avatar.png',
'tests/fixtures/resume.pdf',
]);
// Clear files ที่เลือกอยู่
await page.locator('input[type="file"]').setInputFiles([]);
สร้าง buffer โดยไม่ต้องมีไฟล์จริง:
// tested: Playwright v1.50+, Node.js 20+
await page.locator('input[type="file"]').setInputFiles({
name: 'test.txt',
mimeType: 'text/plain',
buffer: Buffer.from('hello world'),
});
3.5 File Download¶
Pattern เดียวกับ popup — start listening before click:
// tested: Playwright v1.50+, Node.js 20+
const downloadPromise = page.waitForEvent('download');
await page.click('[data-testid="btn-download"]');
const download = await downloadPromise;
// ชื่อไฟล์ที่ browser แนะนำ (จาก Content-Disposition header)
const filename = download.suggestedFilename();
console.log(filename); // เช่น 'report-2024.pdf'
// path ชั่วคราวที่ Playwright save ไว้
const tempPath = await download.path();
// save ไปยัง path ที่ต้องการ
await download.saveAs(`/tmp/downloads/${filename}`);
3.6 Shadow DOM¶
Shadow DOM เป็น web component ที่มี DOM tree แยกออกมาจาก DOM หลัก — เหมือน "กล่องปิด" ที่ซ่อน implementation ไว้ ทำให้ CSS และ JavaScript ภายนอกเข้าไปยุ่งได้ยาก ใช้สร้าง reusable components เช่น <my-counter> ใน demo app ของเรา แต่ Playwright pierce through open shadow roots อัตโนมัติ ไม่ต้อง workaround ใดๆ ต่างจาก Selenium ที่ต้องใช้ JavaScript execute เพื่อเข้าถึง
// tested: Playwright v1.50+, Node.js 20+
// เข้าถึง element ภายใน shadow root โดยตรง
// Playwright ค้นหาข้าม shadow boundary อัตโนมัติ
await page.locator('[data-testid="shadow-counter"]').locator('#inc').click();
// หรือใช้ getByRole/getByText ก็ได้ — Playwright pierce shadow root ให้
await page.getByRole('button', { name: 'Increment counter' }).click(); // aria-label="Increment counter"
⚠️ closed shadow root (attachShadow({ mode: 'closed' })) Playwright ไม่สามารถ pierce ผ่านได้ — แต่ในทางปฏิบัติ app ที่ทดสอบได้จริงมักใช้ open shadow root เพราะ closed mode ไม่ได้ให้ security จริงๆ
3.7 Device Emulation¶
devices คือ registry ของ device configurations ที่ Playwright ship มาให้ — เมื่อ spread เข้า context จะ set viewport, userAgent, deviceScaleFactor, isMobile, hasTouch ให้อัตโนมัติ
// tested: Playwright v1.50+, Node.js 20+
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
projects: [
{
name: 'Desktop Chrome',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'Mobile iPhone 15 Pro',
use: {
...devices['iPhone 15 Pro'], // exact device name ที่ Playwright รองรับ
locale: 'th-TH',
timezoneId: 'Asia/Bangkok',
},
},
],
});
device names ที่ Playwright รองรับสำหรับ iPhone 15:
| Device Name | หมายเหตุ |
|---|---|
'iPhone 15' |
Portrait |
'iPhone 15 landscape' |
Landscape |
'iPhone 15 Plus' |
|
'iPhone 15 Pro' |
|
'iPhone 15 Pro Max' |
สร้าง context สำหรับ mobile ใน test โดยตรง:
// tested: Playwright v1.50+, Node.js 20+
test('mobile geolocation', async ({ browser }) => {
const mobileContext = await browser.newContext({
...devices['iPhone 15 Pro'],
geolocation: { latitude: 13.7563, longitude: 100.5018 }, // กรุงเทพ
permissions: ['geolocation'],
});
const page = await mobileContext.newPage();
await page.goto('/location-based-feature');
// ทดสอบ geolocation-based feature
await expect(page.getByText('กรุงเทพมหานคร')).toBeVisible();
await mobileContext.close();
});
3.8 page.emulateMedia()¶
ใช้เปลี่ยน media type หรือ color scheme ระหว่าง test — มีประโยชน์สำหรับ:
- Print stylesheet testing — verify ว่า
@media printCSS ทำงานถูกต้อง - Dark mode testing — verify ว่า
@media (prefers-color-scheme: dark)แสดงสีถูกต้อง
// tested: Playwright v1.50+, Node.js 20+
test('page renders correctly in print mode', async ({ page }) => {
await page.goto('/');
// switch ไป print mode
await page.emulateMedia({ media: 'print' });
// verify page ยังแสดงผลถูกต้องใน print mode
await expect(page.locator('main')).toBeVisible();
await expect(page.locator('nav')).toBeVisible();
});
test('dark mode shows correct background color', async ({ page }) => {
await page.goto('/visual');
// เปิด dark mode ผ่าน UI toggle
await page.getByTestId('btn-theme-toggle').click();
await expect(page.getByTestId('current-theme')).toHaveText('Current: Dark');
// verify dark mode background color (#1a1a1a = rgb(26, 26, 26))
const body = page.locator('body');
await expect(body).toHaveCSS('background-color', 'rgb(26, 26, 26)');
});
3.9 Low-Level Mouse และ Keyboard¶
ส่วนใหญ่ใช้ locator.click(), locator.type() ก็พอ แต่บางกรณีต้องการ low-level control:
Mouse:
// tested: Playwright v1.50+, Node.js 20+
// drag จาก point A ไป point B
await page.mouse.move(100, 200);
await page.mouse.down(); // กด mouse button ค้างไว้
await page.mouse.move(300, 400);
await page.mouse.up(); // ปล่อย
// double click ที่พิกัดเฉพาะ
await page.mouse.dblclick(250, 300);
Keyboard:
// tested: Playwright v1.50+, Node.js 20+
// keyboard shortcuts
await page.keyboard.press('Control+A'); // Select All
await page.keyboard.press('Control+C'); // Copy
await page.keyboard.press('Escape');
// type text
await page.keyboard.type('Hello World');
// hold key ค้างไว้ระหว่าง action
await page.keyboard.down('Shift');
await page.click('[data-testid="last-item"]'); // Shift+Click เพื่อ select range
await page.keyboard.up('Shift');
⚠️ สำหรับ drag-and-drop ทั่วไป แนะนำใช้ locator.dragTo() แทน page.mouse เพราะ readable กว่าและ reliable กว่า:
3.10 RF/Selenium vs Playwright: เปรียบเทียบ¶
| Feature | Robot Framework + Selenium | Playwright |
|---|---|---|
| Popup / New Tab | Select Window title=... keyword |
page.waitForEvent('popup') |
| iframe | Select Frame id=myframe + switch context |
frameLocator('iframe[name=...]') — ไม่ต้อง switch |
| Dialog (alert) | AlertKeywords library |
page.on('dialog', ...) built-in |
| Mobile emulation | DesiredCapabilities + ChromeOptions |
devices['iPhone 15 Pro'] built-in |
| Shadow DOM | XPath >> หรือ JS shadowRoot.querySelector() |
Automatic — pierce open shadow root |
| File upload | Choose File keyword |
setInputFiles() |
| File download | Wait For Download keyword หรือ custom |
page.waitForEvent('download') |
| Print media | ไม่มี built-in | page.emulateMedia({ media: 'print' }) |
4. ตัวอย่าง 3 ระดับ¶
Beginner — Popup + Dialog จาก /advanced¶
// tested: Playwright v1.50+, Node.js 20+
// tests/beginner-popup-dialog.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Popup and Dialog basics', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/advanced');
});
test('popup opens and has correct title', async ({ page }) => {
// เริ่ม listen ก่อน trigger
const popupPromise = page.waitForEvent('popup');
await page.click('[data-testid="btn-open-popup"]');
const popup = await popupPromise;
await popup.waitForLoadState();
await expect(popup).toHaveTitle(/Todo/);
// popup ควรมี content
await expect(popup.getByRole('heading')).toBeVisible();
await popup.close();
});
test('alert dialog is handled', async ({ page }) => {
// register handler ก่อน trigger
let alertMessage = '';
page.once('dialog', async dialog => {
alertMessage = dialog.message();
await dialog.accept();
});
await page.click('[data-testid="btn-alert"]');
// หลัง dialog ปิด page ควร continue ได้
await expect(page.locator('[data-testid="btn-alert"]')).toBeVisible();
console.log('Alert message:', alertMessage);
});
test('confirm dialog — accept shows success', async ({ page }) => {
page.once('dialog', async dialog => {
expect(dialog.type()).toBe('confirm');
await dialog.accept();
});
await page.click('[data-testid="btn-confirm"]');
// หลัง accept ควรมี feedback บน page
await expect(page.locator('[data-testid="confirm-result"]')).toHaveText(/confirmed/i);
});
test('prompt dialog — submit answer', async ({ page }) => {
page.once('dialog', async dialog => {
expect(dialog.type()).toBe('prompt');
await dialog.accept('Playwright');
});
await page.click('[data-testid="btn-prompt"]');
// onclick: r ? 'Hello, ' + r + '!' : 'Cancelled'
await expect(page.locator('[data-testid="prompt-result"]')).toHaveText('Hello, Playwright!');
});
});
Intermediate — Mobile Emulation สำหรับ Responsive Design¶
สถานการณ์: ทีมพัฒนา e-commerce site แจ้งว่า checkout button หายไปใน mobile view หลัง deploy ล่าสุด — เขียน test ยืนยัน:
// tested: Playwright v1.50+, Node.js 20+
// tests/mobile-responsive.spec.ts
import { test, expect, devices } from '@playwright/test';
// ทดสอบใน multiple viewports
const mobileDevices = [
{ name: 'iPhone 15 Pro', device: devices['iPhone 15 Pro'] },
{ name: 'iPhone 15 Pro Max', device: devices['iPhone 15 Pro Max'] },
];
for (const { name, device } of mobileDevices) {
test.describe(`Responsive checkout — ${name}`, () => {
test.use({ ...device });
test('add-to-cart button is visible and tappable on shop page', async ({ page }) => {
await page.goto('/shop');
// รอ products โหลด
await page.waitForSelector('[data-testid="product-grid"]');
// mobile viewport ควรเห็น add-to-cart button
const addToCart = page.locator('[data-testid^="btn-add-cart-"]').first();
await expect(addToCart).toBeVisible();
// ต้องอยู่ใน viewport จริง (ไม่ใช่แค่ exist ใน DOM)
await expect(addToCart).toBeInViewport();
await addToCart.tap(); // ใช้ tap() สำหรับ touch device
// cart badge ใน nav อัปเดต
await expect(page.locator('[data-testid="cart-count"]')).toHaveText('1');
});
test('navigation links are accessible on mobile', async ({ page }) => {
await page.goto('/');
// nav links ควรเห็นได้บน mobile
const navShop = page.getByTestId('nav-shop');
await expect(navShop).toBeVisible();
// tap nav link แทน click
await navShop.tap();
await expect(page).toHaveURL(/\/shop/);
});
test('product grid shows correct columns in portrait vs landscape', async ({ browser }) => {
// --- PORTRAIT MODE ---
const portraitCtx = await browser.newContext({
viewport: { width: 390, height: 844 },
});
const portraitPage = await portraitCtx.newPage();
await portraitPage.goto('/shop');
// portrait ควรแสดง 1 column layout (stacked)
const portraitCards = portraitPage.locator('[data-testid^="product-card-"]');
const portraitFirstCard = portraitCards.nth(0);
const portraitSecondCard = portraitCards.nth(1);
const portraitFirstBox = await portraitFirstCard.boundingBox();
const portraitSecondBox = await portraitSecondCard.boundingBox();
// ส่วน Y ต่างกัน (stacked vertically)
expect(portraitSecondBox!.y).toBeGreaterThan(portraitFirstBox!.y + portraitFirstBox!.height);
// ส่วน X เหมือนกัน (same column)
expect(portraitSecondBox!.x).toBeCloseTo(portraitFirstBox!.x, 10);
await portraitCtx.close();
// --- LANDSCAPE MODE ---
const landscapeCtx = await browser.newContext({
viewport: { width: 844, height: 390 },
});
const landscapePage = await landscapeCtx.newPage();
await landscapePage.goto('/shop');
const landscapeCards = landscapePage.locator('[data-testid^="product-card-"]');
const landscapeFirstCard = landscapeCards.nth(0);
const landscapeSecondCard = landscapeCards.nth(1);
const landscapeFirstBox = await landscapeFirstCard.boundingBox();
const landscapeSecondBox = await landscapeSecondCard.boundingBox();
// landscape ควรแสดง 2 columns (side-by-side)
// ส่วน Y เหมือนกัน (same row)
expect(landscapeSecondBox!.y).toBeCloseTo(landscapeFirstBox!.y, 10);
// ส่วน X ต่างกัน (different columns)
expect(landscapeSecondBox!.x).toBeGreaterThan(landscapeFirstBox!.x + landscapeFirstBox!.width);
await landscapeCtx.close();
});
});
}
Advanced — Synthesis: iframe + Shadow DOM + Download ใน Flow เดียว¶
สถานการณ์: ระบบ HR portal ที่มี document viewer (iframe) แสดงเอกสาร, shadow DOM widget สำหรับ digital signature, และ download button ที่ generate PDF หลังเซ็น — เขียน end-to-end test:
// tested: Playwright v1.50+, Node.js 20+
// tests/hr-document-signing.spec.ts
import { test, expect } from '@playwright/test';
import * as path from 'path';
import * as fs from 'fs';
test('HR document signing flow — iframe + shadow DOM + download', async ({ page }) => {
await page.goto('/hr/documents/offer-letter-2024');
// --- STEP 1: ตรวจสอบ document content ใน iframe ---
const docViewer = page.frameLocator('iframe[data-testid="document-viewer"]');
// verify document loaded correctly
await expect(docViewer.getByRole('heading', { name: /Offer Letter/i })).toBeVisible();
const candidateName = await docViewer.locator('[data-field="candidate-name"]').textContent();
expect(candidateName).toMatch(/John Doe/);
// scroll ไปท้าย document ใน iframe ก่อน sign
await docViewer.locator('[data-testid="document-end"]').scrollIntoViewIfNeeded();
// --- STEP 2: Digital Signature Widget (Shadow DOM) ---
// shadow-signature-pad คือ web component ที่มี shadow root
const signatureWidget = page.locator('shadow-signature-pad');
// Playwright auto-pierces open shadow root
const clearBtn = signatureWidget.locator('[data-action="clear"]');
const canvas = signatureWidget.locator('canvas.signature-canvas');
// clear ก่อนวาด
await clearBtn.click();
// วาด signature ด้วย mouse (simulate drag)
const canvasBound = await canvas.boundingBox();
if (!canvasBound) throw new Error('Signature canvas not found');
const startX = canvasBound.x + 50;
const startY = canvasBound.y + canvasBound.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
await page.mouse.move(startX + 100, startY - 20);
await page.mouse.move(startX + 200, startY + 10);
await page.mouse.up();
// verify signature มีข้อมูล (canvas ไม่ว่าง)
const signatureData = await signatureWidget.locator('[data-testid="signature-data"]').inputValue();
expect(signatureData.length).toBeGreaterThan(0);
// --- STEP 3: Submit + Download PDF ---
// dialog confirm ก่อน submit
page.once('dialog', async dialog => {
expect(dialog.type()).toBe('confirm');
expect(dialog.message()).toMatch(/ยืนยันการลงนาม/);
await dialog.accept();
});
// start listening for download ก่อน click submit
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: /ลงนามและดาวน์โหลด/i }).click();
const download = await downloadPromise;
// verify download
const filename = download.suggestedFilename();
expect(filename).toMatch(/offer-letter.*\.pdf$/i);
// save และตรวจสอบไฟล์
const savePath = path.join('test-results', 'downloads', filename);
// ✅ สร้าง directory ก่อนบันทึกไฟล์
fs.mkdirSync(path.dirname(savePath), { recursive: true });
await download.saveAs(savePath);
expect(fs.existsSync(savePath)).toBe(true);
const fileSize = fs.statSync(savePath).size;
expect(fileSize).toBeGreaterThan(1000); // PDF ต้องมีเนื้อหาจริง
// --- STEP 4: Verify success state ---
await expect(page.getByText(/เอกสารลงนามสำเร็จ/)).toBeVisible();
await expect(page.locator('[data-testid="signature-status"]')).toHaveText('Signed');
});
5. Common Mistakes¶
❌ ไม่ register dialog handler ก่อน trigger action ที่เปิด dialog
// ผิด — handler ลงทะเบียนหลัง click, อาจพลาด dialog
await page.click('[data-testid="btn-confirm"]');
page.once('dialog', async dialog => await dialog.accept()); // สาย
✅ Register handler ก่อน action เสมอ
// ถูก — handler พร้อมรับก่อน action trigger
page.once('dialog', async dialog => await dialog.accept());
await page.click('[data-testid="btn-confirm"]');
หากไม่ handle: test อาจ hang หรือ Playwright auto-dismiss แล้ว test ล้มเหลวจาก state ที่ไม่ถูกต้อง (source: https://playwright.dev/docs/dialogs)
❌ ใช้ page.frames() loop ด้วย index เพื่อหา iframe
// ผิด — brittle มาก, ถ้า iframe เพิ่ม/ลดจะ index เปลี่ยน
const frame = page.frames()[1];
await frame.fill('#username', 'John');
✅ ใช้ frameLocator() ด้วย attribute selector
// ถูก — stable ใช้ attribute ที่ meaningful
const frame = page.frameLocator('iframe[data-testid="embedded-iframe"]');
await frame.getByTestId('input-new-todo').fill('Learn Playwright');
การใช้ index แบบ hardcode พังทันทีที่ order ของ iframe ใน page เปลี่ยน (source: https://playwright.dev/docs/frames)
❌ ใช้ page.mouse.click() พิกัด hardcode แทน locator
// ผิด — พิกัด hardcode พังเมื่อ layout เปลี่ยนหรือ viewport ต่างกัน
await page.mouse.click(234, 567);
✅ ใช้ locator.click() เป็น default, ใช้ page.mouse เฉพาะเมื่อจำเป็น
// ถูก — stable ตาม semantic หรือ test id
await page.getByRole('button', { name: 'Submit' }).click();
// ถ้าต้องการ mouse interaction จริง (drag, hover precision) ใช้ boundingBox()
const box = await page.locator('#target').boundingBox();
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
(source: Playwright best practices — https://playwright.dev/docs/best-practices)
❌ คาดหวังว่า devices['...'] emulate performance จริงของ hardware
// เข้าใจผิด — ใช้ measure performance แล้วคาดว่าจะเป็น iPhone จริง
const startTime = Date.now();
await page.goto('/heavy-page');
const loadTime = Date.now() - startTime;
expect(loadTime).toBeLessThan(1000); // อาจ pass บน desktop แต่ fail บน iPhone จริง
✅ devices[...] emulate viewport, userAgent, touch เท่านั้น — ไม่ใช่ CPU/GPU
ใช้สำหรับ: layout testing, CSS breakpoint, touch interaction, user-agent sniffing — ไม่ใช่ performance benchmark (source: https://playwright.dev/docs/emulation)
❌ interact กับ cross-origin iframe content
// ผิด — จะ throw error หรือ timeout สำหรับ cross-origin iframe
const frame = page.frameLocator('iframe[src="https://maps.google.com"]');
await frame.getByRole('button').click(); // Error: cross-origin frame
✅ รับรู้ข้อจำกัด — cross-origin iframe ไม่สามารถ inspect ได้
ถ้าต้องการทดสอบ integration กับ third-party widget ใน cross-origin iframe ให้ mock API response แทน หรือ test widget แยกใน environment ที่ควบคุม origin ได้ (source: https://playwright.dev/docs/frames)
6. สรุปบท¶
ก่อนดูเฉลย ลองตอบ 3 คำถามนี้ด้วยตัวเองก่อน:
คำถาม 1: คุณต้องการทดสอบว่าหลังจาก click ปุ่ม "Delete" จะมี confirm dialog ขึ้นมา ถ้า user กด OK จะลบสำเร็จ ถ้ากด Cancel จะไม่ลบ — คุณจะเขียน 2 test cases นี้อย่างไร? อะไรคือสิ่งที่ต้องทำก่อน await page.click('[data-testid="btn-delete"]') เสมอ?
คำถาม 2: frameLocator() ต่างจาก page.frame() อย่างไร? เมื่อไหรควรใช้ locator.contentFrame() แทน page.frameLocator()?
คำถาม 3: สถาปนิกในทีมบอกให้คุณเขียน test ที่รัน automated ทุกคืนเพื่อตรวจสอบว่า print stylesheet ซ่อน navigation bar และ sidebar อย่างถูกต้อง — คุณจะ set up อย่างไร?
ดูเฉลย
**เฉลย:** **คำถาม 1**: ต้อง `page.once('dialog', handler)` ก่อน click เสมอ สำหรับ 2 cases: - Case "กด OK": `page.once('dialog', async d => await d.accept())` ก่อน click, แล้ว assert ว่า item ถูกลบ - Case "กด Cancel": `page.once('dialog', async d => await d.dismiss())` ก่อน click, แล้ว assert ว่า item ยังอยู่ ถ้าไม่ register handler ก่อน Playwright อาจ auto-dismiss แล้ว test ไม่ได้ทดสอบ behavior จริง **คำถาม 2**: `frameLocator()` return FrameLocator ที่ scope ภายใน iframe — ทุก locator ที่ chain ต่อจะหาใน iframe นั้น ไม่ต้อง "switch context" — `page.frame()` return Frame object แบบ Selenium-style ที่ older API ส่วน `locator.contentFrame()` ใช้เมื่อคุณมี Locator ของ iframe element อยู่แล้ว (เพิ่มใน v1.43) แต่ต้องการ interact กับ content ข้างใน — ทั้งสองให้ผลเหมือนกัน แค่ starting point ต่างกัน **คำถาม 3**: สร้าง test ที่เรียก `await page.emulateMedia({ media: 'print' })` ก่อน assert, ตรวจสอบด้วย `expect(locator).not.toBeVisible()` สำหรับ nav และ sidebar — ใส่ใน project แยกหรือ tag ว่า `@print` แล้วรันใน CI cron job ทุกคืน7. Pre-chapter Retrieval สำหรับบทถัดไป¶
บทที่ 15 จะพูดถึง CI/CD Integration และ Reporting — ก่อนอ่าน ลองนึกดูว่า:
- Playwright test suite ที่ดีควร integrate กับ CI pipeline อย่างไร? sharding ช่วยอะไรใน CI?
playwright.config.tsมี option อะไรบ้างที่ควร set แตกต่างกันระหว่าง local และ CI environment?