บทที่ 6 · Part 1 — Foundations

Locators, Auto-waiting and Assertions

เลือก locator จาก user-facing contract ใช้ strictness, auto-waiting และ web-first assertions แทน timeout เดาสุ่ม

Locators, Auto-waiting and Assertions

ปุ่ม Reserve ถูกห่อ <div> เพิ่ม 1 ชั้นหลัง design refactor แล้ว tests ที่ใช้ .workshop-card > div:nth-child(3) button พังทั้ง suite ทั้งที่ผู้ใช้ยังเห็นและกดปุ่มชื่อเดิมได้ Selector ที่ผูกกับโครง DOM ทำให้ test ตรวจ implementation แทน user contract

จบบทนี้คุณจะ

เลือก locator ตาม semantic priority ใช้ chaining/filter/strictness อย่างตั้งใจ เข้าใจ actionability และเขียน web-first assertions ที่รอสถานะจริงโดยไม่ใช้ arbitrary timeout

Locator Priority ของคอร์ส

หยุดที่ตัวเลือกแรกที่ unique และสื่อ user-facing contract ได้:

  1. Role + accessible name สำหรับ button, link, heading, textbox, dialog, table และ control มาตรฐาน
  2. data-testid สำหรับ custom component ที่ไม่มี role/name เชื่อถือได้จริง
  3. Label หรือ placeholder สำหรับ form เมื่อ role/name ยังแก้ให้ unique ไม่ได้
page.getByRole('button', { name: 'Reserve seat' })
page.getByRole('heading', { name: 'Upcoming workshops' })
page.getByRole('textbox', { name: 'Email' })
page.getByTestId('seat-map')
page.getByLabel('Experience level')
page.getByPlaceholder('name@example.com')

getByText() เหมาะกับ copy ที่ผู้ใช้เห็นและคงที่ เช่น empty state แต่ไม่ควรใช้ dynamic user data ที่ซ้ำหลายตำแหน่ง ให้ scope ด้วย role/test id ของ container ก่อน CSS/XPath, class name และ DOM position ไม่ใช่ primary locator เพราะเปลี่ยนจาก styling/refactor ได้โดย behavior ไม่เปลี่ยน

ถ้า application ไม่มี accessible name อย่ารีบใส่ test id ให้ control มาตรฐาน แก้ markup ให้ผู้ใช้ assistive technology ใช้ได้ก่อน แล้ว test จะได้ contract ที่มีความหมายขึ้นพร้อมกัน

Scope ก่อนใช้ Index

สมมติ list มี workshop หลายใบ อย่าเลือก .nth(2) เพราะลำดับเปลี่ยนตามข้อมูล:

const card = page
  .getByRole('article')
  .filter({ has: page.getByRole('heading', { name: 'Reliable Browser Tests' }) })

await card.getByRole('button', { name: 'Reserve seat' }).click()
await expect(card.getByText('Reserved')).toBeVisible()

ใช้ .first(), .last() หรือ .nth() เมื่อ “ตำแหน่ง” คือ behavior เช่นตรวจ row แรกหลัง sort หรือ field ที่ผู้ใช้เพิ่มตาม index เท่านั้น ไม่ใช้ index เพื่อแก้ locator ambiguity ที่ควรแก้ด้วย semantic scope

Dynamic locator วางเป็น function ได้:

const elements = {
  workshopCard: (title: string) =>
    page.getByRole('article').filter({
      has: page.getByRole('heading', { name: title, exact: true }),
    }),
}

Strictness คือ Feedback

Locator actions ที่คาด element เดียวจะ throw เมื่อ match หลายตัว นี่ไม่ใช่สิ่งรบกวนแต่เป็นสัญญาณว่า test contract กำกวม เช่นมีปุ่ม Delete 3 ปุ่ม ควร scope ไป row ของ workshop ที่ต้องการ:

const row = page.getByRole('row').filter({ hasText: 'TypeScript Clinic' })
await row.getByRole('button', { name: 'Delete' }).click()

อย่าแก้ด้วย .first() โดยไม่รู้ว่าตัวแรกคือสิ่งที่ behavior ระบุจริงหรือไม่ ดู locator strictness เพื่อเข้าใจ operation ที่ต้อง unique

Auto-waiting รอ Actionability

ก่อน click() Playwright ตรวจว่า locator resolve 1 element, visible, stable, receives events และ enabled ตาม action ที่ใช้ fill() และ action อื่นมี actionability checks ของตน การมี element ใน DOM จึงยังไม่พอ ถ้ามี overlay บัง Playwright จะรอแทนการคลิกทะลุ

force: true ข้าม checks บางส่วนและอาจทำให้ test ทำสิ่งที่ผู้ใช้ทำไม่ได้ ใช้เฉพาะเมื่อ behavior ต้องการ ทดสอบ forced interaction จริง ไม่ใช้กลบ animation, overlay หรือ bug ใน UI

Web-first Assertions

Assertion ที่รับ Locator จะ retry จน matcher ผ่านหรือ timeout:

await expect(page.getByRole('heading', { name: 'Reservation confirmed' })).toBeVisible()
await expect(page.getByRole('status')).toContainText('Seat reserved')
await expect(page.getByRole('button', { name: 'Reserve seat' })).toBeDisabled()
await expect(page.getByLabel('Seats remaining')).toHaveValue('0')
await expect(page).toHaveURL(/\/reservations\/[^/]+$/)

เปรียบเทียบกับ assertion ที่อ่านค่าครั้งเดียว:

// อ่อนแอ: textContent ถูกอ่านก่อน UI update แล้วไม่ retry
expect(await page.getByRole('status').textContent()).toContain('reserved')

// เหมาะกว่า: locator assertion poll state ให้
await expect(page.getByRole('status')).toContainText('reserved')

ใช้ generic expect.poll() เมื่อรอ non-DOM condition ที่ไม่มี matcher ตรง เช่น API inquiry หลัง background job:

await expect
  .poll(async () => {
    const response = await request.get(`/reservations/${reservationId}`)
    return (await response.json()).status
  })
  .toBe('CONFIRMED')

กำหนด timeout ตาม business latency ของ condition นั้น ไม่เพิ่ม global timeout เพื่อช่วยจุดเดียว

รอ Event ที่อธิบายได้

ห้ามใช้ page.waitForTimeout(5_000) เป็น synchronization ใน test ปกติ เพราะเร็วเกินไปบน CI แต่เสียเวลา 5 วินาทีทุกครั้งเมื่อระบบเร็ว ให้เริ่ม wait ก่อน action ถ้าต้องจับ response ที่ action จะ trigger:

const reservationResponse = page.waitForResponse((response) =>
  response.url().endsWith('/api/reservations') && response.request().method() === 'POST',
)

await page.getByRole('button', { name: 'Reserve seat' }).click()
await expect((await reservationResponse).status()).toBe(201)
await expect(page.getByRole('heading', { name: 'Reservation confirmed' })).toBeVisible()

ถ้าผู้ใช้สนใจเฉพาะ visible result มักไม่ต้องรอ response เลย assertion ปลายทางพอและ coupling ต่ำกว่า รอ network เมื่อ status/payload เป็น claim หรือช่วยแยกสาเหตุที่จำเป็นจริง

Assertion ตรวจผล ไม่ตรวจพิธีกรรม

หลัง submit form อย่า assert ว่าปุ่มถูกคลิกหรือ spinner เคยแสดง เว้นแต่ spinner เป็น requirement ตรวจ confirmation, persisted state หรือ error ที่ผู้ใช้ต้องใช้ตัดสินใจ ชื่อ test และ assertion ควรสอดคล้องกัน:

test('BOOK-VAL-001 - keeps the form open and identifies a missing topic', async ({ page }) => {
  await page.goto('/organizer/workshops/new')
  await page.getByRole('button', { name: 'Create workshop' }).click()

  await expect(page.getByRole('dialog', { name: 'New workshop' })).toBeVisible()
  await expect(page.getByText('Topic is required')).toBeVisible()
  await expect(page.getByLabel('Topic')).toHaveAttribute('aria-invalid', 'true')
})

รายการตรวจสอบ

  • Role/name เป็น locator แรกสำหรับ standard controls
  • Test id ใช้กับ custom boundary ไม่ได้ซ่อน accessibility defect
  • Dynamic data ถูก scope ใน container ที่ชัด
  • Index ใช้เฉพาะเมื่อ position เป็น behavior
  • Assertions รับ Locator และตรวจ result ที่ผู้ใช้/contract เห็น
  • ไม่มี arbitrary timeout, CSS/XPath primary selector หรือ force ที่ไร้เหตุผล

สรุปบทนี้

Locator ที่ดีคือ contract ระหว่างผู้ใช้กับ interface ส่วน auto-waiting และ web-first assertion ทำให้ test รอสถานะที่มีความหมาย Strict failure จึงช่วยเปิดเผย UI/test ambiguity แทนที่จะเป็น noise

อ่านเพิ่มเติม