ก่อนอ่านบทนี้ ลองตอบ:¶
page.getByRole('button', { name: 'Submit' })กับlocator.click()— อะไรที่ทำให้เกิดการ query DOM จริงๆ และทำไมความแตกต่างนี้ถึงสำคัญ?- ถ้าหน้า web มีปุ่ม "Delete" 5 ตัว (ทุก todo item มีปุ่มตัวเอง) และคุณต้องการลบเฉพาะ item ที่ชื่อ "Buy groceries" — คุณจะ locate ปุ่มนั้นอย่างไรโดยไม่ใช้
nth()?
เฉลย:
getByRole()แค่สร้าง Locator object ไว้ ยังไม่แตะ DOM เลย — การ query DOM จริงเกิดตอนที่ใช้.click()หรือexpect().toBeVisible()ความสำคัญคือ Playwright จะ re-evaluate locator ซ้ำทุกครั้งที่ retry ทำให้รองรับ element ที่ยังโหลดไม่เสร็จได้- ใช้ chaining + filter:
page.getByRole('listitem').filter({ hasText: 'Buy groceries' }).getByRole('button', { name: 'Delete' }).click()— หา listitem ที่มี text นั้นก่อน แล้วหาปุ่ม Delete ใน scope ของมัน
บทที่ 5: Actions & Assertions¶
1. วัตถุประสงค์¶
หลังอ่านบทนี้คุณจะ:
- เรียกใช้ action พื้นฐาน (
click,fill,press,hover,dragTo) และเลือกได้ถูกต้องระหว่างfill()กับpressSequentially() - อธิบาย actionability checks ที่ Playwright ทำโดยอัตโนมัติก่อนทุก action ได้ว่ามีอะไรบ้างและทำไม
- เขียน web-first assertion ด้วย
expect()และอธิบายความต่างจาก regular assertion ที่ไม่ retry - ใช้ soft assertions เพื่อ collect ทุก error ในรอบเดียว แทนที่จะหยุดที่ error แรก
- สร้าง custom matcher ด้วย
expect.extend()สำหรับ assertion ที่ใช้ซ้ำในโปรเจค - เปรียบเทียบ actions/assertions ใน Playwright กับ Robot Framework + Selenium ได้ชัดเจน
2. ทำไมต้องรู้? (Why)¶
ลองนึกภาพ test นี้:
// ❌ แบบที่ developer ใหม่มักเขียน
await page.click('#submit-btn');
await page.waitForTimeout(2000); // รอ 2 วินาทีเผื่อ
const isVisible = await page.locator('.success-msg').isVisible();
expect(isVisible).toBe(true); // ถ้าไม่ visible จะ fail ทันที ไม่ retry
test นี้มีปัญหา 3 จุด:
- waitForTimeout(2000) เดาเวลา — machine เร็วผ่าน, CI server ช้าพัง
- isVisible() คือ snapshot ณ ขณะที่ call — ถ้า message โผล่หลัง 50ms test พังโดยไม่ควรพัง
- expect(isVisible).toBe(true) ไม่ retry เลย ถ้าค่าเป็น false ณ วินาทีนั้น = fail
แต่ถ้าเขียนแบบ Playwright-native:
// ✅ แบบที่ถูก
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.locator('.success-msg')).toBeVisible();
สองบรรทัดนี้ทำงานได้ถูกต้องเพราะ Playwright เข้าใจ "ความเป็นจริงของ browser" — element ใช้เวลาปรากฏ, animation ยังไม่เสร็จ, network ยังโหลด — และ framework จัดการให้ทั้งหมดโดยไม่ต้องเดาเวลา
บทนี้คือการเข้าใจว่า Playwright จัดการความซับซ้อนเหล่านั้นอย่างไร
3. เนื้อหาหลัก¶
3.1 Actionability Checks — สิ่งที่ Playwright ตรวจก่อนทุก action¶
ก่อน Playwright จะ click หรือ fill ทุกครั้ง มันจะตรวจก่อนว่า element "พร้อม" จริงๆ — เพราะถ้าคลิกปุ่มที่ยังโหลดอยู่ หรือกรอก input ที่ disabled อยู่ มันก็ไม่มีประโยชน์
Playwright เรียกการตรวจนี้ว่า actionability checks (source: https://playwright.dev/docs/actionability)
| Check | ความหมาย | ตัวอย่างที่ "ยังไม่ผ่าน" |
|---|---|---|
| Visible | element มองเห็นได้จริง ไม่ซ่อนอยู่ | ปุ่มที่ display: none หรือ opacity: 0 |
| Stable | element ไม่กำลังขยับหรือ animate | ปุ่มที่กำลัง slide เข้ามา ยัง animate อยู่ |
| Receives Events | ไม่มีอะไรทับอยู่ข้างบน ทำให้ click ผ่านไปไม่ได้ | loading overlay หรือ modal ที่ขึ้นทับ |
| Enabled | ไม่ถูก disable — ใช้งานได้ | ปุ่ม "Submit" ที่สีเทา กดไม่ได้ |
| Editable | เป็น input ที่แก้ไขได้ ไม่ใช่ read-only | input ที่ readonly อ่านได้แต่พิมพ์ไม่ได้ |
ไม่ใช่ทุก action ต้องผ่านทุก check — ขึ้นอยู่กับว่าทำอะไร:
| Action | ต้องผ่าน check อะไรบ้าง |
|---|---|
click(), dblclick(), check(), uncheck() |
visible + stable + receives events + enabled |
hover(), dragTo() |
visible + stable + receives events |
fill(), clear() |
visible + enabled + editable |
selectOption() |
visible + enabled |
press(), pressSequentially() |
ไม่มี — ทำงานทันที |
ทำไมต้องรู้เรื่องนี้?
เวลา test ค้างอยู่นานผิดปกติ ส่วนใหญ่มาจาก actionability check ที่รอ element "พร้อม" เช่น รอ loading overlay หายไปก่อน click — Playwright จัดการให้อัตโนมัติ ไม่ต้องเพิ่ม waitForTimeout เอง
3.2 Actions — การกระทำบน Element¶
click() และ dblclick()¶
// partial example — see Section 5 for runnable version
// click ธรรมดา
await page.getByRole('button', { name: 'Submit' }).click();
// double-click — ใช้สำหรับ edit inline (เช่น todo list)
await page.getByTestId('todo-text-1').dblclick();
// click ที่ตำแหน่งเฉพาะ หรือพร้อม modifier key
await page.getByRole('button').click({ button: 'right' }); // right-click
await page.getByText('Item').click({ modifiers: ['Shift'] }); // Shift+click
dblclick() ใช้บ่อยใน inline-edit pattern — เช่น todo app ที่ double-click บน text เพื่อเปิด edit mode
fill() vs pressSequentially()¶
นี่คือความแตกต่างที่สำคัญ — เลือกผิดทำให้ test พังหรือได้ผลลัพธ์ผิด:
fill(value) — เหมือน copy-paste ใส่ค่าทั้งหมดทีเดียว:
- ล้าง input เดิมออก แล้วใส่ค่าใหม่ในครั้งเดียวเลย
- เหมาะกับ input ทั่วไป เช่น email, ชื่อ, ที่อยู่
- เร็วมาก
pressSequentially(text) — เหมือนพิมพ์จริงๆ ทีละตัวอักษร:
- จำลองการกดแป้นพิมพ์ทีละตัว เหมือนผู้ใช้นั่งพิมพ์จริงๆ
- เหมาะกับ input ที่ "ฟัง" การพิมพ์ระหว่างทาง เช่น:
- masked input — phone number ที่พอพิมพ์ 0812345678 แล้วจัดรูปแบบเป็น (081) 234-5678 ให้เองอัตโนมัติ — ถ้าใช้ fill() ค่าจะเป็น 0812345678 เฉยๆ เพราะ library ไม่รู้ว่าผู้ใช้พิมพ์
- autocomplete — search box ที่ต้องพิมพ์ก่อนถึงจะโชว์ dropdown
- real-time validation — form ที่แสดง error ขณะพิมพ์
- ช้ากว่า fill() แต่ simulate พฤติกรรมผู้ใช้จริงได้
// partial example — see Section 5 for runnable version
// ✅ ใช้ fill() สำหรับ input ทั่วไป
await page.getByLabel('Email').fill('user@example.com');
// ✅ ใช้ pressSequentially() สำหรับ masked input หรือ autocomplete
await page.getByLabel('Phone').pressSequentially('0812345678');
// → จำลองการพิมพ์: 0, 8, 1, 2, ... ทีละตัว trigger validation ระหว่างพิมพ์
// เพิ่ม delay ระหว่าง keystroke ถ้าต้องการ
await page.getByRole('combobox', { name: 'City' }).pressSequentially('Bang', { delay: 100 });
// → พิมพ์ช้าๆ เพื่อให้ autocomplete dropdown โหลดก่อน
clear(), selectOption(), check(), uncheck()¶
// partial example — see Section 5 for runnable version
// ล้าง input
await page.getByLabel('Search').clear();
// เลือก option ใน <select>
await page.getByLabel('Country').selectOption('TH'); // by value
await page.getByLabel('Country').selectOption({ label: 'Thailand' }); // by label
await page.getByLabel('Size').selectOption(['S', 'M']); // multiple
// checkbox และ radio
await page.getByLabel('Remember me').check();
await page.getByLabel('Remember me').uncheck();
await page.getByLabel('Accept terms').setChecked(true); // toggle ตาม boolean
hover() และ dragTo()¶
// partial example — see Section 5 for runnable version
// hover เพื่อเปิด dropdown หรือ tooltip
await page.getByRole('button', { name: 'Options' }).hover();
await expect(page.getByRole('menu')).toBeVisible();
// drag and drop
const source = page.getByTestId('todo-item-1');
const target = page.getByTestId('todo-item-3');
await source.dragTo(target);
press() — keyboard shortcuts¶
// partial example — see Section 5 for runnable version
// Single key
await page.getByLabel('Search').press('Enter');
await page.getByLabel('Username').press('Tab'); // focus ไปที่ field ถัดไป
// Modifier + key
await page.getByRole('textbox').press('Control+A'); // select all
await page.getByRole('textbox').press('Control+C'); // copy
await page.keyboard.press('Escape'); // global keypress
press() ไม่มี actionability check — ทำงานทันทีบน element ที่ focused
3.3 Page-level Navigation¶
// partial example — see Section 5 for runnable version
// ไปยัง URL — default รอ load event (HTML + CSS + JS โหลดครบ)
await page.goto('http://localhost:3000/todos');
// navigation ย้อนหน้า
await page.goBack();
await page.goForward();
await page.reload();
// รัน JavaScript ใน browser context
const title = await page.evaluate(() => document.title);
const count = await page.evaluate(() => {
return document.querySelectorAll('[data-testid^="todo-item-"]').length;
});
page.goto() รอ load event โดย default — แปลว่ารอให้ HTML, CSS, JS โหลดครบก่อน return หลังจากนั้นถ้าต้องการรอ element เฉพาะให้ใช้ expect(locator).toBeVisible() แทนการระบุ waitUntil (source: https://playwright.dev/docs/navigations)
waitUntil option มีไว้สำหรับกรณีพิเศษเท่านั้น:
| option | ใช้เมื่อ |
|---|---|
'domcontentloaded' |
ต้องการ start interact เร็วที่สุด ก่อน JS โหลดเสร็จ |
'networkidle' |
SPA ที่ fetch API data หลัง load event และไม่มี WebSocket |
// ❌ อย่าใช้บน demo app — มี WebSocket เปิดอยู่ตลอด ทำให้ networkidle ไม่มีวันครบ
await page.goto('/login', { waitUntil: 'networkidle' });
// ✅ วิธีที่ถูกต้อง — รอ element ที่ต้องการโดยตรง
await page.goto('/login');
await expect(page.getByTestId('btn-login')).toBeVisible();
3.4 Web-First Assertions — assert แบบ Playwright¶
ใน Playwright มี assertion สองแบบ — ความแตกต่างคือ "รอไหม":
แบบที่ 1 — Regular assertion (ไม่รอ ตรวจทันที):
แบบที่ 2 — Web-first assertion (รอและ retry อัตโนมัติ):
expect(page.locator('.success-msg')).toBeVisible()
// ตรวจซ้ำทุก 100ms จนกว่าจะผ่าน หรือจนครบ 5 วินาที แล้วค่อย fail
ทำไม Playwright ต้องมีแบบที่ 2 — เพราะ web ทำงาน async เสมอ element ไม่ได้โผล่ทันทีหลัง click มักมี network call, animation, หรือ JavaScript ที่ต้องรันก่อน ถ้าตรวจทันทีแบบที่ 1 test จะ fail บ่อยทั้งที่ไม่ควรพัง
กฎง่ายๆ: ถ้าส่ง Locator เข้า expect() → retry อัตโนมัติ
(source: https://playwright.dev/docs/test-assertions)
Visibility & State¶
// partial example — see Section 5 for runnable version
await expect(page.getByRole('alert')).toBeVisible();
await expect(page.getByRole('dialog')).toBeHidden();
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await expect(page.getByRole('button', { name: 'Delete' })).toBeDisabled();
await expect(page.getByLabel('Accept terms')).toBeChecked();
await expect(page.getByLabel('Newsletter')).not.toBeChecked();
await expect(page.getByLabel('Email')).toBeEditable();
await expect(page.getByLabel('Username')).toBeFocused();
Content & Attribute¶
// partial example — see Section 5 for runnable version
// ตรวจ text ทั้งหมด
await expect(page.getByTestId('todo-text-1')).toHaveText('Buy groceries');
// ตรวจว่า contain (ไม่ต้อง exact match)
await expect(page.getByRole('status')).toContainText('Saved');
// regex — สำหรับ dynamic content
await expect(page.getByRole('heading')).toContainText(/\d+ items?/);
// input value
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
// attribute
await expect(page.getByRole('img', { name: 'Avatar' })).toHaveAttribute('src', /\/avatars\//);
// CSS class
await expect(page.getByRole('tab', { name: 'Active' })).toHaveClass(/active/);
// จำนวน element
await expect(page.getByRole('listitem')).toHaveCount(5);
Page-level Assertions¶
// partial example — see Section 5 for runnable version
await expect(page).toHaveURL('http://localhost:3000/dashboard');
await expect(page).toHaveURL(/\/dashboard/); // regex
await expect(page).toHaveTitle('My App - Dashboard');
Accessibility Assertions (v1.44+)¶
// partial example — see Section 5 for runnable version
// ตรวจ accessible name (สิ่งที่ screen reader อ่าน)
await expect(page.getByRole('button')).toHaveAccessibleName('Close dialog');
// ตรวจ ARIA role
await expect(page.locator('.loader')).toHaveRole('progressbar');
toMatchAriaSnapshot() — mention only¶
toMatchAriaSnapshot() ใช้ capture และ compare โครงสร้าง ARIA ของ component ทั้งหมดในครั้งเดียว — เหมาะมากสำหรับ accessibility regression testing บทที่ 16 จะ deep dive เรื่องนี้โดยเฉพาะ
3.5 Soft Assertions — เก็บทุก error ก่อน report¶
ปกติถ้า assertion แรก fail — test หยุดทันที ไม่รู้ว่า assertion ที่เหลือจะผ่านหรือเปล่า:
// ❌ แบบปกติ — หยุดที่ error แรก
await expect(page.getByTestId('first-name')).toHaveValue('John'); // fail → หยุดเลย
await expect(page.getByTestId('last-name')).toHaveValue('Doe'); // ไม่ได้รัน
await expect(page.getByTestId('email')).toHaveValue('john@x.com'); // ไม่ได้รัน
// รู้แค่ว่า first-name ผิด ไม่รู้ field อื่น
Soft assertion แก้ปัญหานี้ — ถ้า fail จะ จดไว้แต่ไม่หยุด รัน assertion ต่อจนครบ แล้วรายงานทุก error รวมกันตอนจบ:
// ✅ Soft assertions — รัน assertion ทุกข้อ แล้ว report ทุก error รวมกัน
await expect.soft(page.getByTestId('first-name')).toHaveValue('John');
await expect.soft(page.getByTestId('last-name')).toHaveValue('Doe');
await expect.soft(page.getByTestId('email')).toHaveValue('john@x.com');
// ถ้าอยากหยุดแบบ manual หลังจาก soft assertions
// ตรวจด้วย test.info().errors.length
เหมาะมากสำหรับ form validation testing ที่ต้องการ verify หลาย field พร้อมกัน — แทนที่จะต้อง debug ทีละ field
3.6 Custom Matchers — expect.extend()¶
ถ้า assertion เดิมซ้ำในหลายที่ สร้าง custom matcher ได้:
// partial example — see Section 5 for runnable version
// fixtures/matchers.ts
import { expect } from '@playwright/test';
// สร้าง custom matcher
expect.extend({
async toBeLoggedIn(page: any) {
const userMenu = page.getByTestId('user-menu');
const isVisible = await userMenu.isVisible();
return {
pass: isVisible,
message: () => isVisible
? 'Expected page NOT to be logged in, but user menu is visible'
: 'Expected page to be logged in, but user menu is not visible',
};
},
});
// ใช้งาน — อ่านเข้าใจได้ทันทีว่า assert อะไร
await expect(page).toBeLoggedIn();
await expect(page).not.toBeLoggedIn();
3.7 RF/Selenium vs Playwright — Comparison Table¶
| กรณีใช้งาน | Robot Framework + Selenium | Playwright |
|---|---|---|
| Click element | Click Element id:submit + Wait Until Element Is Enabled |
click() — auto-wait ในตัว |
| Fill input | Input Text id:email user@x.com |
fill('user@x.com') |
| Simulate typing | ไม่มี built-in keystroke emulation | pressSequentially('text') |
| Assert visible | Element Should Be Visible css:.msg (fail ทันที ถ้าไม่ visible) |
toBeVisible() (retry จนครบ timeout) |
| Assert text | Element Should Contain css:.status Saved |
toContainText('Saved') (retry) |
| Soft assertions | ไม่มี built-in — ต้องใช้ try/except แล้วเก็บ errors เอง | expect.soft() built-in |
| Wait for condition | Wait Until Element Is Visible css:.modal |
ไม่จำเป็น — toBeVisible() retry เอง |
| Custom assertion | library keyword ที่ซับซ้อน | expect.extend() |
| Check/Uncheck | Select Checkbox / Unselect Checkbox |
check() / uncheck() / setChecked() |
| Keyboard shortcut | Press Key CTRL+A |
press('Control+A') |
ความต่างที่สำคัญที่สุด: ใน RF + Selenium ถ้า assertion fail หมายความว่า "ณ วินาทีนั้น element ไม่ตรงตามเงื่อนไข" แต่ใน Playwright หมายความว่า "หลังพยายาม retry ครบ timeout แล้ว element ยังไม่ตรงตามเงื่อนไข" — ทำให้ test เสถียรกว่ามากสำหรับ async UI
4. ตัวอย่าง 3 ระดับ¶
Beginner: เพิ่ม Todo item และ assert ผลลัพธ์¶
// tested: Playwright v1.50+, Node.js 20+
import { test, expect } from '@playwright/test';
test('add a new todo item', async ({ page }) => {
await page.goto('http://localhost:3000/todos');
// กรอก input และ submit
await page.getByTestId('input-new-todo').fill('Buy groceries');
await page.getByTestId('btn-add-todo').click();
// assert ว่า item ปรากฏใน list
await expect(page.getByText('Buy groceries')).toBeVisible();
// assert ว่า input ถูกล้างหลัง submit
await expect(page.getByTestId('input-new-todo')).toHaveValue('');
});
สังเกตว่าไม่มี waitForTimeout เลย — toBeVisible() retry เองจนกว่า item จะปรากฏ ซึ่ง handle ทั้งกรณีที่ fast และ slow network ได้ในโค้ดเดียว
Intermediate: ตรวจสอบ error state หลาย condition พร้อมกันด้วย Soft Assertions¶
สถานการณ์: Login ล้มเหลว — ต้องการ verify ว่า error message แสดงถูกต้อง, form ยังอยู่บนหน้า, และ URL ไม่ redirect ออกไป ทั้งหมดในครั้งเดียว
// tested: Playwright v1.50+, Node.js 20+
import { test, expect } from '@playwright/test';
test('แสดง error state ครบทุก condition เมื่อ login ไม่สำเร็จ', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Username').fill('wronguser');
await page.getByLabel('Password').fill('wrongpass');
await page.getByRole('button', { name: 'Login' }).click();
// ใช้ soft assertions — ตรวจทุก condition พร้อมกัน แม้บางตัว fail จะไม่หยุด
await expect.soft(page.getByTestId('login-error')).toBeVisible();
await expect.soft(page.getByTestId('login-error')).toContainText('Invalid credentials');
await expect.soft(page.getByLabel('Username')).toBeVisible(); // form ยังแสดงอยู่
await expect.soft(page.getByLabel('Password')).toBeVisible(); // password field ยังอยู่
// ตรวจว่ายังอยู่หน้า login (ไม่ redirect ออกไป)
await expect(page).toHaveURL('/login');
});
ทำไมใช้ soft assertions ที่นี่? ถ้าใช้ expect() ธรรมดา และ login-error ไม่แสดง test จะหยุดทันที — คุณจะไม่รู้ว่า form ยังอยู่หรือไม่ และ URL ยังถูกต้องไหม soft assertions ทำให้เห็นภาพรวมของทุก condition ในรอบเดียว
Advanced: วิเคราะห์ fill() vs pressSequentially() และ Actionability Failures¶
สถานการณ์นี้เป็น diagnosis — ทีมรับ bug report ว่า test ที่ทำงานกับ phone number field พัง เหตุผลไม่ชัด
// partial example — สาธิต concept สำหรับ app ที่มี masked input
// (demo app ไม่มี masked phone field — ปรับ route/selector ให้ตรงกับ app จริงก่อนรัน)
import { test, expect } from '@playwright/test';
// ❌ TEST ที่พัง — ทีมรายงานว่า phone ไม่ถูก format หลัง submit
test.skip('broken — fill() with masked input', async ({ page }) => {
await page.goto('/profile');
// Phone field มี input mask: (___) ___-____
// fill() แทนที่ content ทั้งหมดในครั้งเดียว
await page.getByLabel('Phone Number').fill('0812345678');
// ปัญหา: mask ไม่ได้ trigger เพราะ fill() ไม่ emit keystroke events
// ผลลัพธ์ใน DOM: "0812345678" ไม่ใช่ "(081) 234-5678"
await expect(page.getByLabel('Phone Number')).toHaveValue('(081) 234-5678');
// → FAIL: received "0812345678"
});
// ✅ วิธีที่ถูก — ใช้ pressSequentially() กับ masked input
test.skip('phone number formats correctly with mask', async ({ page }) => {
await page.goto('/profile');
const phoneInput = page.getByLabel('Phone Number');
// pressSequentially() emit keystroke ทีละตัว → mask library รับ event แต่ละตัว
await phoneInput.pressSequentially('0812345678');
// mask library จัด format ให้ระหว่าง typing
await expect(phoneInput).toHaveValue('(081) 234-5678');
await page.getByRole('button', { name: 'Save Profile' }).click();
await expect(page.getByRole('status')).toContainText('Profile updated');
});
// Actionability failure diagnosis
test.describe('actionability checks in practice', () => {
test('button behind overlay fails until overlay closes', async ({ page }) => {
await page.goto('http://localhost:3000/todos');
// เพิ่ม todo แล้วรอ loading spinner (ถ้ามี)
await page.getByTestId('input-new-todo').fill('Learn Playwright');
await page.getByTestId('btn-add-todo').click();
// Playwright auto-wait — ถ้า "btn-add-todo" ยังมี loading overlay บัง
// (receives events check fail) → retry จนกว่า overlay จะหายไป
// ไม่ต้อง waitForTimeout หรือ waitForSelector เพิ่มเติม
// ตรวจ: double-click บน todo text เพื่อ edit
const newTodo = page.getByText('Learn Playwright');
await newTodo.dblclick();
// edit input ควรปรากฏขึ้นมา
const editInput = page.getByTestId(`todo-edit-input`);
await expect(editInput).toBeVisible();
await expect(editInput).toBeFocused();
// เปลี่ยน text
await editInput.fill('Master Playwright');
await editInput.press('Enter');
// ตรวจว่า text อัปเดต
await expect(page.getByText('Master Playwright')).toBeVisible();
await expect(page.getByText('Learn Playwright')).not.toBeVisible();
});
});
การวิเคราะห์ Tradeoff:
| สถานการณ์ | ใช้ | เหตุผล |
|---|---|---|
| Input ทั่วไป (email, name) | fill() |
เร็วกว่า, ง่ายกว่า |
| Masked input (phone, credit card) | pressSequentially() |
Mask library ต้องการ keystroke events |
| Autocomplete/typeahead | pressSequentially() + delay |
ต้องรอ dropdown โหลดระหว่างพิมพ์ |
| OTP / PIN field | pressSequentially() |
แต่ละ field อาจเป็น <input maxlength="1"> แยก |
| Search box ที่ debounce | fill() แล้วรอ toBeVisible() |
fill ครั้งเดียว แล้วรอ dropdown ด้วย assertion |
5. Common Mistakes ❌→✅¶
1. ใช้ isVisible() แทน web-first assertion
❌
✅
เหตุผล: isVisible() คือ snapshot ณ ขณะที่ call — ถ้า banner ยังไม่ขึ้นใน millisecond นั้น ได้ false ทันที ไม่มีการ retry เลย ส่วน toBeVisible() retry จนครบ default timeout 5 วินาที (source: https://playwright.dev/docs/test-assertions)
2. ใช้ waitForTimeout แทน web-first assertion
❌
await page.click('[data-testid="btn-save"]');
await page.waitForTimeout(2000); // รอ 2 วิ เผื่อ
await expect(page.locator('.toast-success')).toBeVisible();
✅
await page.click('[data-testid="btn-save"]');
await expect(page.locator('.toast-success')).toBeVisible();
// toBeVisible() retry ให้เองจนครบ timeout
เหตุผล: waitForTimeout เดาเวลา — ถ้า server ช้ากว่า 2 วิ test พัง ถ้า server เร็ว เสีย 2 วิฟรีทุก test run (source: https://playwright.dev/docs/best-practices)
3. ใช้ fill() กับ input ที่มี mask หรือ real-time validation
❌
// Phone field มี mask library — fill() ข้าม keystroke events ทั้งหมด
await page.getByLabel('Phone').fill('0812345678');
await expect(page.getByLabel('Phone')).toHaveValue('(081) 234-5678');
// FAIL: value ยังเป็น "0812345678" เพราะ mask ไม่ถูก trigger
✅
await page.getByLabel('Phone').pressSequentially('0812345678');
await expect(page.getByLabel('Phone')).toHaveValue('(081) 234-5678');
เหตุผล: fill() เขียน value โดยตรง ไม่ emit keyboard events ตามลำดับ — mask library ต้องการ keydown/keypress/keyup ทีละตัวเพื่อ format ค่าระหว่างพิมพ์ (source: https://playwright.dev/docs/input)
4. ลืมว่า dblclick() auto-wait ให้แล้ว
❌
// ไม่จำเป็นต้อง wait ด้วยตัวเอง
await page.waitForSelector('[data-testid="todo-text-1"]');
await page.locator('[data-testid="todo-text-1"]').dblclick();
✅
// dblclick() auto-wait จนกว่า element จะ visible + stable + receives events
await page.getByTestId('todo-text-1').dblclick();
เหตุผล: dblclick() ผ่าน actionability checks ทั้ง 5 ก่อนทำงาน — visible, stable, receives events, enabled ครบ — ไม่ต้อง wait นำหน้าด้วย (source: https://playwright.dev/docs/actionability)
5. ไม่ใช้ Soft Assertions เมื่อต้องการตรวจหลาย field พร้อมกัน
❌
// test หยุดที่ error แรก — ไม่รู้ว่า field อื่น fail ด้วยหรือไม่
await expect(page.getByTestId('error-name')).toBeVisible(); // fail → หยุด
await expect(page.getByTestId('error-email')).toBeVisible(); // ไม่รัน
await expect(page.getByTestId('error-phone')).toBeVisible(); // ไม่รัน
✅
// รันทุก assertion แล้ว report รวมตอนจบ
await expect.soft(page.getByTestId('error-name')).toBeVisible();
await expect.soft(page.getByTestId('error-email')).toBeVisible();
await expect.soft(page.getByTestId('error-phone')).toBeVisible();
เหตุผล: เมื่อ test form validation การรู้ว่า error ข้อเดียว fail หรือ fail ทั้งหมดมีความหมายต่างกัน — soft assertions ให้ภาพรวมที่สมบูรณ์ในรอบเดียว (source: https://playwright.dev/docs/test-assertions)
6. สรุปบท + Retrieval Questions¶
ในบทนี้คุณได้เรียน:
- Actionability checks — Playwright ตรวจ visible, stable, receives events, enabled, editable อัตโนมัติก่อน action แต่ละ action มี subset ของ checks ที่ต้องผ่านต่างกัน
- fill() vs pressSequentially() — fill แทนที่ content ทันที, pressSequentially emit keystroke ทีละตัว — เลือกตาม behavior ของ input
- Web-first assertions — retry จนครบ timeout ต่างจาก regular assertion ที่ตรวจครั้งเดียว
- Soft assertions —
expect.soft()collect ทุก error ก่อน report ไม่หยุดที่ error แรก - Custom matchers —
expect.extend()สำหรับ assertion ที่ใช้ซ้ำบ่อย
ก่อนอ่านบทถัดไป ลองตอบคำถามเหล่านี้ด้วยตัวเองก่อน (ไม่ต้อง scroll ขึ้นไปดูเฉลย):
คำถามที่ 1: คุณมี <select> dropdown สำหรับเลือก payment method และ <input type="text"> สำหรับใส่ credit card number ที่มี mask ____ ____ ____ ____ — ทั้งสอง field ควรใช้ action อะไร? และทำไม?
คำถามที่ 2: test นี้จะทำงานถูกต้องไหม? ถ้าไม่ — ปัญหาคืออะไรและแก้ยังไง?
await page.click('[data-testid="btn-delete"]');
const isGone = await page.locator('[data-testid="item-1"]').isVisible();
expect(isGone).toBe(false);
คำถามที่ 3: เมื่อไหรควรใช้ soft assertions และเมื่อไหรไม่ควรใช้? ให้ยกตัวอย่างกรณีที่ soft assertions ทำให้ test ดีขึ้น และกรณีที่ไม่ควรใช้