Playwright Cheat Sheet: ฟังก์ชันที่ใช้บ่อย วิธีใช้ และตัวอย่างผลลัพธ์

Playwright Cheat Sheet: ฟังก์ชันที่ใช้บ่อย วิธีใช้ และตัวอย่างผลลัพธ์

เรียงตามความถี่ในการใช้งานจริง (TypeScript / @playwright/test) ทุกตัวอย่างอ้างอิงเว็บ https://www.saucedemo.comตัวย่อที่ใช้ในเอกสาร: page = หน้าเว็บ, locator = ตัวชี้ element, ผลลัพธ์ที่แสดงเป็นตัวอย่างจากการรันจริงบน saucedemo
อ่านเพื่อสอบ: เริ่มจากหัวข้อ “อันดับฟังก์ชันที่ต้องรู้ก่อน” (Playwright Top 25 และ TypeScript Top 25) แล้วค่อยไล่ดูตัวอย่างโค้ดและผลลัพธ์ในหัวข้อ Locator, Actions, Assertions กล่องสีเขียวคือตัวอย่างผลลัพธ์ ใช้ช่องค้นหาด้านบนเพื่อหาฟังก์ชันที่ต้องการ และกดปุ่ม “คัดลอก” ที่มุมกล่องโค้ดได้

1. Locator: การหา element (ใช้บ่อยที่สุด)

Locator เป็น lazy และ auto-wait: ยังไม่ค้นหา element จนกว่าจะสั่ง action หรือ assert

1.1 page.getByRole(role, options) (แนะนำอันดับ 1)

หาจาก role ตาม accessibility เช่น button, link, heading, textbox, checkbox

await page.getByRole('button', { name: 'Login' }).click();
await page.getByRole('heading', { name: 'Products' });
await page.getByRole('link', { name: /twitter/i });   // regex ได้
await page.getByRole('button', { name: 'Add to cart', exact: true });

ผลลัพธ์: คืนค่าเป็น Locator (ไม่ error ถ้ายังไม่เจอ จนกว่าจะใช้งานจริง)

1.2 page.getByText(text, options)
await page.getByText('Sauce Labs Backpack').click();
await page.getByText('Swag Labs', { exact: true });
await expect(page.getByText(/thank you/i)).toBeVisible();
1.3 page.getByLabel(text)

หา input จาก label ที่ผูกไว้ (เหมาะกับฟอร์ม)

await page.getByLabel('Email').fill('a@b.com');
1.4 page.getByPlaceholder(text)
await page.getByPlaceholder('Username').fill('standard_user');
await page.getByPlaceholder('Password').fill('secret_sauce');
1.5 page.getByTestId(id)

ค่าเริ่มต้นอ่านจาก data-testid ถ้าเว็บใช้ data-test (เช่น saucedemo) ให้ตั้งใน config

// playwright.config.ts
use: { testIdAttribute: 'data-test' }

// ในเทสต์
await page.getByTestId('login-button').click();
1.6 page.locator(selector)

ใช้ CSS / XPath / text selector ตรงๆ เมื่อวิธีข้างบนไม่พอ

page.locator('[data-test="username"]');
page.locator('.inventory_item');
page.locator('xpath=//button[text()="Login"]');
page.locator('div.item >> text=Backpack');
1.7 getByAltText / getByTitle
await page.getByAltText('Sauce Labs Backpack').click();   // img alt
await page.getByTitle('Close').click();                    // title attr
1.8 การเลือกตำแหน่ง: first(), last(), nth()
const items = page.locator('.inventory_item');
await items.first().click();
await items.last().click();
await items.nth(2).click();   // index เริ่มที่ 0
1.9 locator.filter({ hasText, has, hasNot, hasNotText })

กรอง element จากกลุ่มที่ locator เจอ

const backpack = page.locator('.inventory_item').filter({ hasText: 'Backpack' });
await backpack.getByRole('button', { name: 'Add to cart' }).click();

const withButton = page.locator('.inventory_item').filter({
  has: page.getByRole('button', { name: 'Remove' }),
});
1.10 Chaining, and(), or()
// ค้นหาต่อเนื่องภายใน element แม่
await page.locator('.cart_item').getByText('Backpack');

// ต้องตรงทั้งสองเงื่อนไข
page.getByRole('button').and(page.getByTitle('Subscribe'));

// อย่างใดอย่างหนึ่ง (เช่น popup ที่อาจขึ้นหรือไม่ขึ้น)
const dialog = page.getByText('Accept cookies').or(page.getByText('ยอมรับคุกกี้'));
await dialog.click();
1.11 locator.count() และ locator.all()
const n = await page.locator('.inventory_item').count();
console.log(n);

for (const item of await page.locator('.inventory_item').all()) {
  console.log(await item.innerText());
}

ผลลัพธ์:

6
Sauce Labs Backpack
$29.99
ADD TO CART
...

2. Navigation: การเปิดหน้าและเปลี่ยนหน้า

2.1 page.goto(url, options)
await page.goto('https://www.saucedemo.com');
await page.goto('/inventory.html');                 // ใช้ baseURL จาก config
await page.goto(url, { waitUntil: 'domcontentloaded' });

ตัวเลือก waitUntil: load (ค่าเริ่มต้น), domcontentloaded, networkidle, commit

2.2 page.reload(), page.goBack(), page.goForward()
await page.reload();
await page.goBack();
await page.goForward();
2.3 page.url() และ page.title()
console.log(page.url());          // sync ไม่ต้อง await
console.log(await page.title());

ผลลัพธ์:

https://www.saucedemo.com/inventory.html
Swag Labs
2.4 page.waitForURL(url)

รอให้ URL เปลี่ยนตามที่ต้องการ (เช่น หลัง redirect)

await page.getByTestId('login-button').click();
await page.waitForURL('**/inventory.html');
await page.waitForURL(/checkout-complete/);

3. Actions: การกระทำกับ element

ทุก action จะ auto-wait จน element visible, stable, enabled และรับ event ได้ ก่อนลงมือทำ

3.1 locator.click(options)
await page.getByTestId('login-button').click();
await locator.click({ button: 'right' });            // คลิกขวา
await locator.click({ modifiers: ['Shift'] });       // กด Shift ค้าง
await locator.click({ position: { x: 10, y: 10 } });
await locator.click({ force: true });                // ข้ามการตรวจ actionability
await locator.click({ clickCount: 2 });
await locator.click({ timeout: 5000 });
3.2 locator.fill(value)

ล้างค่าเดิมแล้วใส่ค่าใหม่ทันที (ใช้กับ input, textarea)

await page.getByTestId('username').fill('standard_user');
await page.getByTestId('password').fill('secret_sauce');
3.3 locator.clear()
await page.getByTestId('username').clear();
3.4 locator.pressSequentially(text, { delay })

พิมพ์ทีละตัวอักษรเหมือนผู้ใช้จริง (แทน type() ที่เลิกแนะนำแล้ว) ใช้เมื่อเว็บมี autocomplete หรือฟัง keydown

await page.getByPlaceholder('Search').pressSequentially('shoes', { delay: 100 });
3.5 locator.press(key)
await page.getByTestId('password').press('Enter');
await locator.press('Control+A');
await locator.press('ArrowDown');
await locator.press('Tab');
3.6 locator.check(), uncheck(), setChecked()
await page.getByLabel('Remember me').check();
await page.getByLabel('Remember me').uncheck();
await page.getByLabel('Subscribe').setChecked(true);
3.7 locator.selectOption(value)

เลือกค่าใน dropdown (<select>) ได้จาก value, label หรือ index

await page.locator('[data-test="product-sort-container"]').selectOption('hilo');
await select.selectOption({ label: 'Price (high to low)' });
await select.selectOption({ index: 2 });
await select.selectOption(['red', 'blue']);   // multi-select

ผลลัพธ์: คืน array ของ value ที่ถูกเลือก เช่น ['hilo']

3.8 locator.hover(), focus(), blur()
await page.getByText('Menu').hover();
await page.getByLabel('Email').focus();
3.9 locator.dblclick(), tap()
await locator.dblclick();
await locator.tap();     // สำหรับ touch device / mobile emulation
3.10 locator.setInputFiles(files)

อัปโหลดไฟล์ผ่าน <input type=file>

await page.locator('input[type=file]').setInputFiles('tests/data/photo.png');
await page.locator('input[type=file]').setInputFiles(['a.png', 'b.png']);
await page.locator('input[type=file]').setInputFiles([]);   // ล้างไฟล์
3.11 locator.dragTo(target)
await page.locator('#item').dragTo(page.locator('#dropzone'));
3.12 locator.scrollIntoViewIfNeeded()
await page.locator('.footer').scrollIntoViewIfNeeded();

4. Assertions: การตรวจสอบด้วย expect (ใช้บ่อยมาก)

expect(locator) แบบ web-first จะ retry อัตโนมัติ จนผ่านหรือหมดเวลา (ค่าเริ่มต้น 5 วินาที) ต่างจาก expect(value) ธรรมดาที่ตรวจครั้งเดียว

4.1 สถานะ element
await expect(locator).toBeVisible();
await expect(locator).toBeHidden();
await expect(locator).toBeEnabled();
await expect(locator).toBeDisabled();
await expect(locator).toBeChecked();
await expect(locator).toBeEditable();
await expect(locator).toBeFocused();
await expect(locator).toBeEmpty();
await expect(locator).toBeAttached();
4.2 ข้อความ
await expect(locator).toHaveText('Thank you for your order!');   // ตรงทั้งหมด
await expect(locator).toContainText('Thank you');                // มีบางส่วน
await expect(locator).toHaveText(/thank you/i);                  // regex
await expect(items).toHaveText(['Backpack', 'Bike Light']);      // หลาย element ตามลำดับ
4.3 ค่าและ attribute
await expect(input).toHaveValue('standard_user');
await expect(link).toHaveAttribute('href', '/inventory.html');
await expect(el).toHaveClass(/active/);
await expect(el).toHaveCSS('color', 'rgb(255, 0, 0)');
await expect(el).toHaveId('main');
4.4 จำนวน
await expect(page.locator('.inventory_item')).toHaveCount(6);
await expect(page.locator('.error')).toHaveCount(0);
4.5 หน้าเว็บ
await expect(page).toHaveURL('https://www.saucedemo.com/checkout-complete.html');
await expect(page).toHaveURL(/inventory/);
await expect(page).toHaveTitle('Swag Labs');
4.6 Assert กับค่าทั่วไป (ไม่ retry)
expect(29.99).toBeGreaterThan(9.99);
expect(prices[0]).toBeGreaterThanOrEqual(prices[1]);
expect(total).toBeCloseTo(32.39, 2);      // ทศนิยม
expect(names).toContain('Sauce Labs Backpack');
expect(names).toEqual(['A', 'B']);
expect(obj).toMatchObject({ id: 1 });
expect(text).toMatch(/[$][0-9]+/);
expect(list).toHaveLength(2);
expect(value).toBeTruthy();
4.7 ตัวปรับแต่ง: not, soft, poll, message, timeout
await expect(locator).not.toBeVisible();           // กลับเงื่อนไข

await expect.soft(locator).toHaveText('A');        // fail แล้วเทสต์ยังรันต่อ
await expect.soft(locator2).toHaveText('B');

await expect.poll(async () => {                    // poll ค่า async ซ้ำจนผ่าน
  const res = await request.get('/api/status');
  return (await res.json()).state;
}, { timeout: 10000 }).toBe('done');

await expect(locator, 'ปุ่ม Login ต้องเห็น').toBeVisible({ timeout: 10000 });

ตัวอย่างผลลัพธ์เมื่อ assert ล้มเหลว:

Error: expect(locator).toHaveText(expected)

Locator: locator('[data-test="complete-header"]')
Expected string: "Thank you for your order!"
Received string: ""
Timeout: 5000ms
4.8 Visual / Snapshot
await expect(page).toHaveScreenshot('home.png');
await expect(locator).toHaveScreenshot();
await expect(page).toHaveScreenshot({ maxDiffPixels: 100, fullPage: true });
expect(await page.content()).toMatchSnapshot('page.html');
await expect(locator).toMatchAriaSnapshot();

รันครั้งแรกจะสร้างภาพอ้างอิง ใช้ npx playwright test --update-snapshots เพื่ออัปเดต

5. การดึงข้อมูลจากหน้าเว็บ (Dynamic Inspection)

5.1 locator.textContent() และ innerText()
const name = await page.locator('.inventory_item_name').first().textContent();
const price = await page.locator('.inventory_item_price').first().innerText();
console.log(name, price);

ผลลัพธ์:

Sauce Labs Backpack $29.99

ความต่าง: textContent รวมข้อความที่ซ่อนอยู่และอาจมี whitespace, innerText ได้เฉพาะข้อความที่มองเห็นและจัดรูปแล้ว

5.2 allTextContents() และ allInnerTexts()

ดึงจากหลาย element เป็น array

const prices = await page.locator('.inventory_item_price').allTextContents();
console.log(prices);

ผลลัพธ์:

[ '$29.99', '$9.99', '$15.99', '$49.99', '$7.99', '$15.99' ]
5.3 inputValue()
const v = await page.getByTestId('username').inputValue();
console.log(v);   // standard_user
5.4 getAttribute(name)
const href = await page.locator('.shopping_cart_link').getAttribute('href');
console.log(href);   // /cart.html
5.5 isVisible(), isEnabled(), isChecked()

คืน boolean ทันที ไม่ retry (ต่างจาก expect) ใช้ใน if เท่านั้น

if (await page.getByText('Accept').isVisible()) {
  await page.getByText('Accept').click();
}
console.log(await checkbox.isChecked());   // false
5.6 locator.evaluate() และ evaluateAll()

รัน JavaScript ในหน้าเว็บ

const color = await locator.evaluate(el => getComputedStyle(el).color);

const names = await page.locator('.inventory_item_name')
  .evaluateAll(els => els.map(e => e.textContent));

const height = await page.evaluate(() => document.body.scrollHeight);
5.7 locator.boundingBox()
console.log(await locator.boundingBox());
// { x: 20, y: 140, width: 240, height: 360 }
5.8 page.content()
const html = await page.content();   // HTML ทั้งหน้าเป็น string
5.9 แปลงราคาเป็นตัวเลข (pattern ที่ใช้บ่อย)
const toNumber = (t: string) => parseFloat(t.replace(/[^0-9.]/g, ''));
const prices = (await page.locator('.inventory_item_price').allTextContents()).map(toNumber);
console.log(Math.max(...prices), Math.min(...prices));   // 49.99 7.99

6. Waiting: การรอ

ส่วนใหญ่ไม่ต้องรองเอง เพราะ auto-wait และ expect จัดการให้แล้ว ใช้เมื่อจำเป็นเท่านั้น

6.1 locator.waitFor({ state })
await page.locator('.spinner').waitFor({ state: 'hidden' });
await page.locator('.result').waitFor({ state: 'visible', timeout: 10000 });

state: attached, detached, visible, hidden

6.2 page.waitForLoadState(state)
await page.waitForLoadState('networkidle');
await page.waitForLoadState('domcontentloaded');
6.3 page.waitForResponse() / waitForRequest()

รอ API call หลังจากกดปุ่ม ให้ครอบด้วย Promise.all เพื่อกัน race condition

const [response] = await Promise.all([
  page.waitForResponse(r => r.url().includes('/api/orders') && r.status() === 200),
  page.getByText('Submit').click(),
]);
console.log(response.status(), await response.json());

ผลลัพธ์:

200 { id: 101, status: 'created' }
6.4 page.waitForFunction(fn)
await page.waitForFunction(() => document.querySelectorAll('.item').length > 5);
6.5 page.waitForTimeout(ms)

หน่วงเวลาตายตัว ใช้เฉพาะตอน debug เพราะทำให้เทสต์ช้าและไม่เสถียร

await page.waitForTimeout(2000);
6.6 page.waitForEvent(event)
const popup = await Promise.all([
  page.waitForEvent('popup'),
  page.getByText('Open').click(),
]).then(([p]) => p);

7. โครงสร้างเทสต์ (Test Runner)

7.1 test() และ test.describe()
import { test, expect } from '@playwright/test';

test.describe('Checkout flow', () => {
  test('login สำเร็จ', async ({ page }) => {
    await page.goto('https://www.saucedemo.com');
    // ...
  });

  test('login ผิดพลาด', async ({ page }) => {
    // ...
  });
});

ฟิกซ์เจอร์ที่ใช้บ่อย: page, context, browser, request, browserName

7.2 Hooks
test.beforeAll(async () => { /* รันครั้งเดียวก่อนทุกเทสต์ */ });
test.beforeEach(async ({ page }) => {
  await page.goto('https://www.saucedemo.com');
});
test.afterEach(async ({ page }) => { /* เคลียร์ข้อมูล */ });
test.afterAll(async () => { /* ปิดการเชื่อมต่อ */ });
7.3 test.step() จัดกลุ่มขั้นตอนในรายงาน
await test.step('Login', async () => {
  await page.getByTestId('username').fill('standard_user');
  await page.getByTestId('login-button').click();
});
await test.step('Sort by price', async () => {
  await page.getByTestId('product-sort-container').selectOption('hilo');
});

ใน HTML report จะแสดงเป็นขั้นตอนที่พับเก็บได้ พร้อมเวลาของแต่ละ step

7.4 Skip / Only / Fixme / Slow / Tag
test.skip('ยังไม่พร้อม', async () => {});
test.only('รันเฉพาะอันนี้', async () => {});
test.fixme('บั๊กที่รู้อยู่แล้ว', async () => {});
test.slow();                                   // เพิ่ม timeout 3 เท่า
test.skip(browserName === 'webkit', 'ไม่รองรับ');
test('smoke', { tag: '@smoke' }, async ({ page }) => {});

รันเฉพาะแท็ก: npx playwright test --grep @smoke

7.5 Data-driven (วนรันหลายชุดข้อมูล)
const users = ['standard_user', 'problem_user', 'performance_glitch_user'];
for (const u of users) {
  test(`login ด้วย ${u}`, async ({ page }) => {
    await page.goto('https://www.saucedemo.com');
    await page.getByTestId('username').fill(u);
    await page.getByTestId('password').fill('secret_sauce');
    await page.getByTestId('login-button').click();
    await expect(page).toHaveURL(/inventory/);
  });
}
7.6 Custom fixture
import { test as base } from '@playwright/test';

const test = base.extend<{ loggedInPage: Page }>({
  loggedInPage: async ({ page }, use) => {
    await page.goto('https://www.saucedemo.com');
    await page.getByTestId('username').fill('standard_user');
    await page.getByTestId('password').fill('secret_sauce');
    await page.getByTestId('login-button').click();
    await use(page);
  },
});

test('ใช้ที่ login แล้ว', async ({ loggedInPage }) => {
  await expect(loggedInPage).toHaveURL(/inventory/);
});
7.7 ผลลัพธ์การรันที่พบบ่อย
Running 3 tests using 3 workers

  ✓  1 [chromium] › login.spec.ts:5:1 › login สำเร็จ (1.8s)
  ✓  2 [chromium] › login.spec.ts:12:1 › login ผิดพลาด (1.2s)
  ✘  3 [chromium] › cart.spec.ts:8:1 › checkout (6.1s)

  1 failed
  2 passed (9.4s)

8. Browser, Context, Page

เมื่อใช้ @playwright/test ระบบสร้างให้อัตโนมัติ ส่วนนี้ใช้เมื่อเขียนสคริปต์เองหรือต้องการควบคุมละเอียด

8.1 เปิด browser เอง
import { chromium } from 'playwright';

const browser = await chromium.launch({ headless: false, slowMo: 300 });
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
const page = await context.newPage();
await page.goto('https://www.saucedemo.com');
await browser.close();

browser ที่ใช้ได้: chromium, firefox, webkit

8.2 ตัวเลือกของ context
await browser.newContext({
  viewport: { width: 390, height: 844 },
  locale: 'th-TH',
  timezoneId: 'Asia/Bangkok',
  geolocation: { latitude: 13.75, longitude: 100.5 },
  permissions: ['geolocation'],
  userAgent: 'MyAgent/1.0',
  colorScheme: 'dark',
  httpCredentials: { username: 'u', password: 'p' },
});

จำลองมือถือ: const { devices } = require('playwright'); browser.newContext({ ...devices['iPhone 13'] })

8.3 บันทึกและใช้ session ซ้ำ (storageState)

login ครั้งเดียวแล้วนำไปใช้กับทุกเทสต์ ประหยัดเวลามาก

// บันทึกหลัง login
await context.storageState({ path: 'auth.json' });

// ใช้ใน config
use: { storageState: 'auth.json' }

// หรือใช้ในไฟล์เทสต์เดียว
test.use({ storageState: 'auth.json' });
8.4 หลายแท็บและ popup
const [newPage] = await Promise.all([
  context.waitForEvent('page'),
  page.getByText('Open in new tab').click(),
]);
await newPage.waitForLoadState();
console.log(await newPage.title());

console.log(context.pages().length);   // 2
await newPage.close();
8.5 Cookies และ Storage
await context.addCookies([{ name: 'token', value: 'abc', url: 'https://www.saucedemo.com' }]);
console.log(await context.cookies());
await context.clearCookies();

await page.evaluate(() => localStorage.setItem('k', 'v'));
const v = await page.evaluate(() => localStorage.getItem('k'));

9. Frames, Dialogs, Downloads

9.1 iframe
const frame = page.frameLocator('#payment-iframe');
await frame.getByLabel('Card number').fill('4242424242424242');
await frame.getByRole('button', { name: 'Pay' }).click();
9.2 Dialog (alert, confirm, prompt)

ต้องผูก handler ก่อนที่ dialog จะเด้ง

page.on('dialog', async dialog => {
  console.log(dialog.type(), dialog.message());
  await dialog.accept();          // หรือ dialog.dismiss() / accept('ข้อความ')
});
await page.getByText('Delete').click();

ผลลัพธ์:

confirm Are you sure you want to delete?
9.3 ดาวน์โหลดไฟล์
const [download] = await Promise.all([
  page.waitForEvent('download'),
  page.getByText('Export CSV').click(),
]);
console.log(download.suggestedFilename());   // report.csv
await download.saveAs('downloads/' + download.suggestedFilename());
9.4 Shadow DOM, Keyboard, Mouse
// Locator ทะลุ shadow DOM ให้อัตโนมัติ
await page.locator('my-component').getByText('OK').click();

await page.keyboard.type('Hello');
await page.keyboard.press('Control+A');
await page.keyboard.down('Shift');

await page.mouse.move(100, 200);
await page.mouse.click(100, 200);
await page.mouse.wheel(0, 500);    // scroll ลง

10. Network: ดักและจำลอง request

10.1 page.route() mock response
await page.route('**/api/products', route =>
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([{ id: 1, name: 'Mock item', price: 9.99 }]),
  })
);
10.2 route.abort() และ route.continue()
// บล็อกรูปภาพเพื่อให้โหลดเร็วขึ้น
await page.route('**/*.{png,jpg,jpeg}', route => route.abort());

// แก้ header แล้วส่งต่อ
await page.route('**/api/**', route =>
  route.continue({ headers: { ...route.request().headers(), 'x-test': '1' } })
);

// จำลอง server error
await page.route('**/api/orders', route => route.fulfill({ status: 500 }));
10.3 ฟัง event request / response
page.on('request', req => console.log('>>', req.method(), req.url()));
page.on('response', res => console.log('<<', res.status(), res.url()));
page.on('console', msg => console.log('console:', msg.text()));
page.on('pageerror', err => console.log('error:', err.message));

ผลลัพธ์:

>> GET https://www.saucedemo.com/
<< 200 https://www.saucedemo.com/
console: Failed to load resource: 404
10.4 request fixture (ทดสอบ API ตรง ๆ)
test('API test', async ({ request }) => {
  const res = await request.post('https://api.example.com/users', {
    data: { name: 'Somchai' },
    headers: { Authorization: 'Bearer xxx' },
  });
  expect(res.ok()).toBeTruthy();
  expect(res.status()).toBe(201);
  const body = await res.json();
  expect(body.name).toBe('Somchai');
});

เมธอดอื่น: request.get(), put(), patch(), delete(), head(), fetch()

11. Screenshot, Video, Trace

11.1 Screenshot
await page.screenshot({ path: 'home.png' });
await page.screenshot({ path: 'full.png', fullPage: true });
await locator.screenshot({ path: 'item.png' });
const buffer = await page.screenshot();       // ได้ Buffer
11.2 PDF (เฉพาะ Chromium headless)
await page.pdf({ path: 'page.pdf', format: 'A4' });
11.3 Video และ Trace
// playwright.config.ts
use: {
  screenshot: 'only-on-failure',
  video: 'retain-on-failure',
  trace: 'on-first-retry',     // หรือ 'on', 'retain-on-failure'
}

เปิดดู trace ย้อนหลังทีละ step:

npx playwright show-trace test-results/xxx/trace.zip

12. Debug

await page.pause();        // เปิด Inspector หยุดรอให้ debug ด้วยมือ
npx playwright test --debug            # เปิด Inspector
npx playwright test --ui               # UI mode (แนะนำ)
PWDEBUG=1 npx playwright test          # Inspector ผ่าน env
DEBUG=pw:api npx playwright test       # log ทุก API call

13. ไฟล์ตั้งค่า playwright.config.ts

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  timeout: 30_000,                       // timeout ต่อเทสต์
  expect: { timeout: 5_000 },            // timeout ของ expect
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: [['html'], ['list']],
  use: {
    baseURL: 'https://www.saucedemo.com',
    testIdAttribute: 'data-test',
    headless: true,
    viewport: { width: 1280, height: 720 },
    actionTimeout: 10_000,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox',  use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit',   use: { ...devices['Desktop Safari'] } },
    { name: 'mobile',   use: { ...devices['iPhone 13'] } },
  ],
});

14. คำสั่ง CLI ที่ใช้บ่อย

npm init playwright@latest                 # สร้างโปรเจกต์
npx playwright install                     # ติดตั้ง browser
npx playwright install --with-deps         # รวม dependency ของ OS

npx playwright test                        # รันทั้งหมด
npx playwright test login.spec.ts          # รันไฟล์เดียว
npx playwright test -g 'checkout'          # กรองตามชื่อเทสต์
npx playwright test --project=chromium     # เลือก browser
npx playwright test --headed               # เห็น browser
npx playwright test --workers=1            # รันทีละเทสต์
npx playwright test --retries=2
npx playwright test --repeat-each=5        # รันซ้ำ ใช้หาเทสต์ flaky
npx playwright test --last-failed          # รันเฉพาะที่พังรอบก่อน
npx playwright test --update-snapshots
npx playwright test --reporter=line

npx playwright show-report                 # เปิด HTML report
npx playwright show-trace trace.zip        # เปิด trace
npx playwright codegen https://www.saucedemo.com
npx playwright codegen --device='iPhone 13' --save-storage=auth.json URL

15. Best Practices แบบสรุป

ให้ใช้ getByRole หรือ getByTestId ก่อน locator แบบ CSS ที่ผูกกับโครงสร้างหน้า และใช้ expect(locator) แทนการอ่านค่าแล้วเทียบเอง เพราะมี auto-retry หลีกเลี่ยง waitForTimeout ให้ใช้ waitFor, waitForResponse หรือ expect แทน ทำให้แต่ละเทสต์เป็นอิสระต่อกัน (login ด้วย storageState แทนการพึ่งเทสต์ก่อนหน้า) ใช้ toBeCloseTo กับตัวเลขทศนิยม เช่น ราคาและภาษี และเก็บ trace กับ screenshot ไว้เฉพาะตอนเทสต์พังเพื่อประหยัดพื้นที่

เอกสารทางการ: https://playwright.dev/docs/api/class-playwright

16. อันดับฟังก์ชันที่ต้องรู้ก่อน (เรียงจากใช้บ่อยสุด)

สัญลักษณ์: ⭐⭐⭐ = ใช้ทุกเทสต์ / ⭐⭐ = ใช้บ่อย / ⭐ = ใช้เป็นครั้งคราว

16.1 Playwright Top 25
อันดับฟังก์ชันความถี่ใช้ทำอะไร
1page.goto(url)⭐⭐⭐เปิดหน้าเว็บ
2locator.click()⭐⭐⭐คลิก
3locator.fill(text)⭐⭐⭐กรอกช่องข้อความ
4expect(locator).toBeVisible()⭐⭐⭐ตรวจว่า element แสดงอยู่
5page.getByRole()⭐⭐⭐หา element ตาม role + ชื่อ
6expect(locator).toHaveText()⭐⭐⭐ตรวจข้อความ
7expect(page).toHaveURL()⭐⭐⭐ตรวจ URL
8page.locator(css)⭐⭐⭐หาด้วย CSS / XPath
9page.getByTestId()⭐⭐⭐หาด้วย data-testid
10page.getByText()⭐⭐หาด้วยข้อความ
11locator.selectOption()⭐⭐เลือก dropdown
12locator.allTextContents()⭐⭐ดึงข้อความหลาย element เป็น array
13locator.count()⭐⭐นับจำนวน element
14expect(locator).toHaveCount()⭐⭐ตรวจจำนวน element
15locator.textContent() / innerText()⭐⭐ดึงข้อความ element เดียว
16test.beforeEach()⭐⭐เตรียมก่อนทุกเทสต์
17test.step()⭐⭐จัดกลุ่มขั้นตอนในรายงาน
18locator.filter()⭐⭐กรอง element ที่เจอ
19locator.check() / uncheck()⭐⭐ติ๊ก checkbox / radio
20page.waitForResponse()⭐⭐รอ API response
21locator.waitFor()⭐⭐รอ element เปลี่ยนสถานะ
22page.screenshot()⭐ถ่ายภาพหน้าจอ
23page.route()⭐mock / ดัก request
24locator.press()⭐กดแป้นพิมพ์
25request.get/post()⭐ทดสอบ API
16.2 TypeScript Top 25 (ฟังก์ชันกับค่าตัวแปร)
อันดับฟังก์ชันชนิดใช้ทำอะไร
1array.map()Arrayแปลงทุกสมาชิก
2array.filter()Arrayคัดสมาชิกตามเงื่อนไข
3array.forEach() / for...ofArrayวนลูป
4array.lengthArray/Stringจำนวนสมาชิก / ความยาว
5array.includes() / string.includes()Array/Stringมีค่านี้หรือไม่
6array.find()Arrayหาตัวแรกที่ตรงเงื่อนไข
7array.reduce()Arrayรวมค่าเป็นค่าเดียว (เช่น ผลรวม)
8array.sort()Arrayเรียงลำดับ
9string.replace()Stringแทนที่ข้อความ
10string.trim()Stringตัดช่องว่างหัวท้าย
11string.split()Stringตัดสตริงเป็น array
12array.join()Arrayรวม array เป็นสตริง
13parseFloat() / Number()Numberแปลงสตริงเป็นเลข
14number.toFixed()Numberปัดทศนิยม (ได้สตริง)
15Math.max() / Math.min()Mathค่าสูงสุด / ต่ำสุด
16Math.round() / floor() / ceil()Mathปัดเลข
17Object.keys() / values() / entries()Objectดึง key / value / คู่
18JSON.stringify() / JSON.parse()JSONแปลง object กับสตริง
19Template literalStringแทรกตัวแปรในสตริง
20array.slice()Arrayตัดช่วงสมาชิก
21string.startsWith() / endsWith()Stringตรวจต้น / ท้ายสตริง
22string.toLowerCase() / toUpperCase()Stringเปลี่ยนตัวพิมพ์
23array.some() / every()Arrayมีบางตัว / ทุกตัวตรงเงื่อนไข
24Promise.all()Promiseรอหลาย async พร้อมกัน
25?. และ ??Operatorป้องกัน null / ค่าเริ่มต้น

รายละเอียดตัวอย่างและผลลัพธ์ของ TypeScript อยู่ในหัวข้อ 19

17. Matcher ของ expect ทั้งหมด (เติมส่วนที่ขาด)

แบ่ง 3 กลุ่ม: (A) ค่าทั่วไป ไม่ retry, (B) Locator / Page แบบ web-first retry, (C) ตัวช่วยเสริม

17.1 กลุ่ม A: ค่าทั่วไป (เรียงตามความนิยม)
expect(5).toBe(5);                        // เทียบแบบ Object.is (ชนิดพื้นฐาน)
expect({ a: 1 }).toEqual({ a: 1 });       // เทียบเนื้อใน object / array
expect(x).toBeTruthy();                   // truthy
expect(x).toBeFalsy();                    // falsy: 0, '', null, undefined, false, NaN
expect(arr).toHaveLength(3);
expect(arr).toContain('a');               // array มีค่านี้
expect(text).toContain('Thank');          // สตริงมีข้อความนี้
expect(text).toMatch(/thank/i);
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(5).toBeLessThan(10);
expect(5).toBeLessThanOrEqual(5);
expect(0.1 + 0.2).toBeCloseTo(0.3, 5);   // เลขทศนิยม
expect(obj).toMatchObject({ id: 1 });     // มี property เหล่านี้ (ที่เหลือไม่สน)
expect(obj).toHaveProperty('user.name', 'Somchai');
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(0).toBeDefined();                  // ไม่ใช่ undefined (null, 0, '' ผ่าน)
expect(NaN).toBeNaN();
expect({ a: 1, b: undefined }).toStrictEqual({ a: 1 });   // fail: เข้มกว่า toEqual
expect(arr).toContainEqual({ id: 1 });    // array มี object ที่เนื้อเท่ากัน
expect(() => fn()).toThrow('error msg');
expect(new Date()).toBeInstanceOf(Date);

ความต่างที่มักสับสน:

Matcherผลลัพธ์ตัวอย่าง
expect({a:1}).toBe({a:1})fail เพราะคนละ reference
expect({a:1}).toEqual({a:1})ผ่าน
expect(null).toBeDefined()ผ่าน (null ไม่ใช่ undefined)
expect(null).toBeFalsy()ผ่าน
expect([]).toBeTruthy()ผ่าน (array ว่างยัง truthy)

ข้อความเมื่อ fail:

Error: expect(received).toBe(expected) // Object.is equality

Expected: 5
Received: 4
Error: expect(received).toBeGreaterThanOrEqual(expected)

Expected: >= 49.99
Received:    29.99
Error: expect(received).toContain(expected) // indexOf

Expected value: "Thank"
Received string: "Checkout: Overview"
17.2 Asymmetric matcher (ใช้ซ้อนใน toEqual / toMatchObject)
expect(user).toEqual({
  id: expect.any(Number),                   // ชนิดตรง ค่าอะไรก็ได้
  name: expect.stringContaining('Som'),
  email: expect.stringMatching(/@.+/),
  tags: expect.arrayContaining(['qa']),
  meta: expect.objectContaining({ ok: true }),
  token: expect.anything(),                 // ไม่ใช่ null / undefined
});
17.3 กลุ่ม B: Locator / Page matcher (retry อัตโนมัติ)
Matcherตรวจอะไรตัวอย่าง
toBeVisible() ⭐⭐⭐แสดงอยู่await expect(loc).toBeVisible()
toHaveText() ⭐⭐⭐ข้อความตรงทั้งหมดtoHaveText('Products')
toContainText() ⭐⭐⭐ข้อความมีบางส่วนtoContainText('Thank')
toHaveURL() ⭐⭐⭐URL ของ pagetoHaveURL(/inventory/)
toHaveCount() ⭐⭐⭐จำนวน elementtoHaveCount(6)
toHaveValue() ⭐⭐ค่าใน inputtoHaveValue('abc')
toBeEnabled() / toBeDisabled() ⭐⭐เปิด / ปิดใช้งานtoBeDisabled()
toBeChecked() ⭐⭐ติ๊กอยู่toBeChecked({ checked: false })
toHaveAttribute() ⭐⭐attributetoHaveAttribute('href', '/cart.html')
toHaveTitle() ⭐⭐title ของ pagetoHaveTitle('Swag Labs')
toBeHidden() ⭐⭐ซ่อน / ไม่มีใน DOMtoBeHidden()
toHaveClass() ⭐classtoHaveClass(/active/)
toHaveCSS() ⭐สไตล์toHaveCSS('color', 'rgb(0, 0, 0)')
toBeEmpty() ⭐ว่างtoBeEmpty()
toBeFocused() ⭐ถูก focustoBeFocused()
toBeEditable() ⭐แก้ไขได้toBeEditable()
toBeAttached() ⭐อยู่ใน DOMtoBeAttached()
toBeInViewport() ⭐อยู่ในหน้าจอtoBeInViewport({ ratio: 0.5 })
toHaveId() ⭐idtoHaveId('main')
toHaveValues() ⭐หลายค่าใน multi-selecttoHaveValues(['a', 'b'])
toHaveJSProperty() ⭐JS propertytoHaveJSProperty('loop', true)
toHaveAccessibleName() ⭐accessible nametoHaveAccessibleName('Login')
toHaveAccessibleDescription() ⭐accessible descriptiontoHaveAccessibleDescription('...')
toHaveRole() ⭐ARIA roletoHaveRole('button')
toContainClass() ⭐มี class นั้นอยู่toContainClass('btn')
toHaveScreenshot() ⭐เทียบภาพtoHaveScreenshot('a.png')
toMatchAriaSnapshot() ⭐เทียบ accessibility treetoMatchAriaSnapshot(...)
toBeOK() ⭐⭐API response 2xxawait expect(res).toBeOK()

ตัวอย่างผลลัพธ์เมื่อ fail:

Error: expect(locator).toHaveCount(expected)

Locator: locator('.inventory_item')
Expected: 6
Received: 5
Timeout: 5000ms
Error: expect(page).toHaveURL(expected)

Expected string: "https://www.saucedemo.com/checkout-complete.html"
Received string: "https://www.saucedemo.com/checkout-step-two.html"
17.4 กลุ่ม C: ตัวช่วยเสริมของ expect
ตัวช่วยใช้ทำอะไร
expect(...).notกลับเงื่อนไข
expect.soft(...)assert แล้วไม่หยุดเทสต์ถ้า fail
expect.poll(fn)เรียก fn ซ้ำจนผ่าน (ใช้กับค่า async)
expect(fn).toPass()รันบล็อกซ้ำจนไม่ throw
expect.configure({ timeout, soft })สร้าง expect เวอร์ชันตั้งค่าเอง
expect.extend({...})สร้าง matcher ใหม่เอง
expect(value, 'ข้อความ')ใส่ข้อความอธิบายตอน fail
await expect(async () => {
  const res = await page.request.get('/api/status');
  expect(res.status()).toBe(200);
}).toPass({ intervals: [500, 1000], timeout: 10_000 });

const slowExpect = expect.configure({ timeout: 15_000 });
await slowExpect(page.getByText('Done')).toBeVisible();

expect.extend({
  toBePositive(received: number) {
    const pass = received > 0;
    return { pass, message: () => `expected ${received} to be positive` };
  },
});

18. Playwright API ทั้งหมด (ดัชนีฉบับเต็ม)

เรียงในแต่ละตารางจากใช้บ่อยไปน้อย ฟังก์ชันที่ไม่มีดาวคือใช้เฉพาะงานเฉพาะทาง รายละเอียดตัวอย่างของตัวที่ใช้บ่อยอยู่ในหัวข้อ 1 ถึง 15 ตัวอย่างสั้นในตารางนี้ใส่ผลลัพธ์ไว้ในวงเล็บ

18.1 Page
เมธอดใช้ทำอะไรตัวอย่าง (ผลลัพธ์)
goto(url) ⭐⭐⭐เปิด URLawait page.goto('/') (คืน Response)
locator(sel) ⭐⭐⭐สร้าง Locatorpage.locator('.item')
getByRole/Text/Label/Placeholder/TestId/AltText/Title ⭐⭐⭐หา element แบบ semanticpage.getByRole('button')
url() ⭐⭐⭐URL ปัจจุบันpage.url() ('https://.../inventory.html')
title() ⭐⭐title ของหน้าawait page.title() ('Swag Labs')
waitForURL(url) ⭐⭐รอ URL เปลี่ยนwaitForURL('**/cart.html')
waitForLoadState(s) ⭐⭐รอสถานะโหลด'networkidle'
waitForResponse(pred) ⭐⭐รอ responseดูหัวข้อ 6.3
waitForRequest(pred) ⭐⭐รอ requestwaitForRequest('**/api/**')
waitForEvent(name) ⭐⭐รอ event'popup', 'download', 'dialog'
waitForFunction(fn) ⭐รอเงื่อนไขใน browserwaitForFunction(() => window.ready)
waitForTimeout(ms) ⭐หน่วงเวลา (ใช้ debug)waitForTimeout(1000)
reload() ⭐⭐โหลดหน้าใหม่await page.reload()
goBack() / goForward() ⭐ย้อน / ไปข้างหน้าawait page.goBack()
screenshot(opts) ⭐⭐ถ่ายภาพscreenshot({ path: 'a.png' })
evaluate(fn) ⭐⭐รัน JS ใน browserevaluate(() => document.title) ('Swag Labs')
evaluateHandle(fn) ⭐รัน JS คืน handleevaluateHandle(() => document.body)
content() ⭐HTML ทั้งหน้าawait page.content() (string)
setContent(html) ⭐ใส่ HTML เองsetContent('<h1>Hi</h1>')
route(url, fn) ⭐⭐ดัก / mock requestดูหัวข้อ 10
unroute(url) ⭐เลิกดักunroute('**/api/**')
routeFromHAR(file) ⭐เล่นซ้ำจากไฟล์ HARrouteFromHAR('a.har')
on(event, fn) / once / off ⭐⭐ฟัง eventpage.on('console', ...)
frameLocator(sel) ⭐⭐เข้า iframeframeLocator('#f')
frame(name) / frames() / mainFrame() ⭐จัดการ Framepage.frames().length (2)
pause() ⭐⭐เปิด Inspectorawait page.pause()
close() ⭐⭐ปิดหน้าawait page.close()
isClosed() ⭐ปิดแล้วหรือยังpage.isClosed() (false)
setViewportSize(size) ⭐⭐ปรับขนาดหน้าต่างsetViewportSize({ width: 390, height: 844 })
viewportSize() ⭐ขนาดปัจจุบัน({ width: 1280, height: 720 })
setDefaultTimeout(ms) ⭐timeout ของ actionsetDefaultTimeout(10000)
setDefaultNavigationTimeout(ms) ⭐timeout ตอน navigatesetDefaultNavigationTimeout(60000)
setExtraHTTPHeaders(h) ⭐เพิ่ม header ทุก request{ 'x-env': 'test' }
addInitScript(fn) ⭐สคริปต์รันก่อนโหลดหน้าaddInitScript(() => {...})
addScriptTag() / addStyleTag() ⭐แทรก script / CSSaddStyleTag({ content: 'a{color:red}' })
exposeFunction(name, fn) ⭐เปิดฟังก์ชัน Node ให้หน้าเว็บเรียกexposeFunction('sha', fn)
emulateMedia(opts) ⭐จำลอง print / dark modeemulateMedia({ colorScheme: 'dark' })
dragAndDrop(src, dst) ⭐ลากวางdragAndDrop('#a', '#b')
pdf(opts) ⭐สร้าง PDF (Chromium)pdf({ path: 'a.pdf' })
bringToFront() ⭐เอาแท็บมาหน้าสุดawait page.bringToFront()
context() ⭐BrowserContext ของหน้านี้page.context()
opener() ⭐หน้าที่เปิดหน้านี้await popup.opener()
video() ⭐อ็อบเจกต์วิดีโอawait page.video()?.path()
workers() ⭐Web Workerpage.workers().length
request ⭐⭐APIRequestContext (ใช้ session เดียวกัน)page.request.get('/api')
keyboard / mouse / touchscreen ⭐อุปกรณ์ inputดูหัวข้อ 18.5
clock ⭐คุมเวลาปลอมawait page.clock.install()

Event ของ page: close, console, crash, dialog, domcontentloaded, download, filechooser, frameattached, framedetached, framenavigated, load, pageerror, popup, request, requestfailed, requestfinished, response, websocket, worker

18.2 Locator
เมธอดใช้ทำอะไรตัวอย่าง (ผลลัพธ์)
click() ⭐⭐⭐คลิกawait loc.click()
fill(v) ⭐⭐⭐กรอกข้อความawait loc.fill('abc')
locator(sel) ⭐⭐⭐ค้นต่อภายในloc.locator('button')
getBy... ⭐⭐⭐หาต่อภายในloc.getByText('x')
first() / last() / nth(i) ⭐⭐⭐เลือกตำแหน่งloc.nth(2)
count() ⭐⭐⭐จำนวน(6)
allTextContents() ⭐⭐⭐ข้อความทุกตัว(['$29.99', '$9.99'])
textContent() ⭐⭐ข้อความ (รวมที่ซ่อน)('Backpack')
innerText() ⭐⭐ข้อความที่มองเห็น('Backpack')
filter(opts) ⭐⭐กรองfilter({ hasText: 'Bike' })
selectOption(v) ⭐⭐เลือก dropdown(['hilo'])
check() / uncheck() / setChecked(b) ⭐⭐ติ๊กawait loc.check()
isVisible() ⭐⭐boolean ทันที(true)
isEnabled() / isDisabled() ⭐⭐เปิด / ปิดใช้งาน(false)
isChecked() ⭐⭐ติ๊กอยู่ไหม(true)
isHidden() / isEditable() ⭐ซ่อน / แก้ไขได้(false)
getAttribute(n) ⭐⭐ค่า attribute('/cart.html')
inputValue() ⭐⭐ค่าใน input('standard_user')
press(key) ⭐⭐กดแป้นpress('Enter')
pressSequentially(t) ⭐⭐พิมพ์ทีละตัวpressSequentially('abc', { delay: 50 })
hover() ⭐⭐วางเมาส์await loc.hover()
waitFor(opts) ⭐⭐รอสถานะwaitFor({ state: 'hidden' })
all() ⭐⭐array ของ Locatorfor (const l of await loc.all())
clear() ⭐⭐ล้างค่าawait loc.clear()
dblclick() ⭐ดับเบิลคลิกawait loc.dblclick()
focus() / blur() ⭐focus / เลิก focusawait loc.focus()
setInputFiles(f) ⭐อัปโหลดไฟล์setInputFiles('a.png')
dragTo(target) ⭐ลากวางdragTo(other)
screenshot() ⭐ถ่ายภาพ elementscreenshot({ path: 'a.png' })
scrollIntoViewIfNeeded() ⭐เลื่อนหาawait loc.scrollIntoViewIfNeeded()
boundingBox() ⭐ตำแหน่งและขนาด({ x, y, width, height })
evaluate(fn) / evaluateAll(fn) ⭐รัน JSevaluateAll(els => els.length)
evaluateHandle(fn) ⭐รัน JS คืน handleloc.evaluateHandle(el => el)
allInnerTexts() ⭐innerText ทุกตัว(['a', 'b'])
innerHTML() ⭐HTML ภายใน('<span>x</span>')
selectText() ⭐เลือกข้อความawait loc.selectText()
tap() ⭐แตะ (touch)await loc.tap()
dispatchEvent(type) ⭐ยิง DOM eventdispatchEvent('click')
and(l) / or(l) ⭐รวม locatora.or(b)
highlight() ⭐ไฮไลต์ตอน debugawait loc.highlight()
ariaSnapshot() ⭐ภาพ accessibility tree (YAML)await loc.ariaSnapshot()
contentFrame() ⭐แปลงเป็น FrameLocatorloc.contentFrame()
page() ⭐Page ของ locatorloc.page()
describe(text) ⭐ตั้งชื่ออ่านง่ายในรายงานloc.describe('ปุ่ม Login')
18.3 BrowserContext และ Browser
เมธอดใช้ทำอะไร
browser.newContext(opts) ⭐⭐สร้าง context (session แยก)
context.newPage() ⭐⭐เปิดแท็บใหม่
context.pages() ⭐⭐แท็บทั้งหมด
context.storageState({ path }) ⭐⭐บันทึก cookies + storage
context.addCookies([...]) / cookies() / clearCookies() ⭐⭐จัดการ cookies
context.waitForEvent('page') ⭐⭐รอแท็บใหม่
context.route() / unroute() ⭐ดัก request ทั้ง context
context.grantPermissions([...]) / clearPermissions() ⭐สิทธิ์ เช่น geolocation
context.setGeolocation(g) ⭐ตั้งพิกัด
context.setOffline(b) ⭐จำลองออฟไลน์
context.setExtraHTTPHeaders(h) ⭐header ทุก request
context.setDefaultTimeout(ms) ⭐timeout ทั้ง context
context.addInitScript(fn) ⭐สคริปต์ก่อนโหลดทุกหน้า
context.exposeFunction() / exposeBinding() ⭐เปิดฟังก์ชัน Node
context.tracing.start() / stop() ⭐⭐บันทึก trace
context.request ⭐APIRequestContext
context.newCDPSession(page) ⭐Chrome DevTools Protocol
context.close() ⭐⭐ปิด context
browser.close() ⭐⭐ปิด browser
browser.newPage() ⭐ทางลัด (สร้าง context + page)
browser.contexts() ⭐context ทั้งหมด
browser.version() ⭐เวอร์ชัน ('131.0.6778.33')
browser.isConnected() ⭐ยังเชื่อมต่อไหม
chromium.launch(opts) ⭐⭐เปิด browser
chromium.launchPersistentContext(dir) ⭐โปรไฟล์ถาวร
chromium.connectOverCDP(url) ⭐ต่อกับ Chrome ที่เปิดอยู่
chromium.connect(wsEndpoint) ⭐ต่อกับ Playwright server
chromium.executablePath() ⭐path ของ browser
18.4 Request, Response, Route, WebSocket
อ็อบเจกต์เมธอด
Requesturl(), method(), headers(), postData(), postDataJSON(), resourceType(), response(), failure(), frame(), redirectedFrom(), redirectedTo(), isNavigationRequest(), timing(), allHeaders(), headerValue(name)
Responseok(), status(), statusText(), url(), headers(), json(), text(), body(), request(), frame(), finished(), allHeaders(), headerValue(name), serverAddr(), securityDetails()
Routefulfill(), continue(), abort(), fallback(), fetch(), request()
WebSocketurl(), isClosed(), waitForEvent('framereceived'), events: framesent, framereceived, close, socketerror
page.on('response', async res => {
  if (res.url().includes('/api/')) {
    console.log(res.status(), res.url());
    console.log(await res.json());
  }
});

ผลลัพธ์:

200 https://example.com/api/products
[ { id: 1, name: 'Backpack' }, { id: 2, name: 'Bike Light' } ]
18.5 Keyboard, Mouse, Touchscreen, Dialog, Download, FileChooser
อ็อบเจกต์เมธอด
page.keyboardpress(key), down(key), up(key), type(text), insertText(text)
page.mousemove(x, y), click(x, y), dblclick(x, y), down(), up(), wheel(dx, dy)
page.touchscreentap(x, y)
Dialogaccept(text?), dismiss(), message(), type(), defaultValue()
DownloadsaveAs(path), path(), suggestedFilename(), url(), failure(), cancel(), delete()
FileChoosersetFiles(files), element(), isMultiple()
18.6 APIRequestContext และ APIResponse
เมธอดใช้ทำอะไร
request.get(url, opts) ⭐⭐GET
request.post(url, { data }) ⭐⭐POST
request.put() / patch() / delete() / head() ⭐เมธอดอื่น
request.fetch(url, { method }) ⭐เมธอดกำหนดเอง
playwright.request.newContext({ baseURL, extraHTTPHeaders }) ⭐⭐สร้าง API client แยก
request.storageState() / dispose() ⭐บันทึก session / ปิด
res.ok() / status() ⭐⭐สถานะ
res.json() / text() / body() ⭐⭐อ่านเนื้อหา
res.headers() / url() / statusText() ⭐ข้อมูลเสริม
const api = await playwright.request.newContext({
  baseURL: 'https://api.example.com',
  extraHTTPHeaders: { Authorization: 'Bearer xxx' },
});
const res = await api.get('/users/1');
console.log(res.status(), await res.json());

ผลลัพธ์:

200 { id: 1, name: 'Somchai' }
18.7 Test Runner (@playwright/test)
APIใช้ทำอะไร
test(name, fn) ⭐⭐⭐นิยามเทสต์
test.describe(name, fn) ⭐⭐⭐จัดกลุ่ม
test.beforeEach / afterEach ⭐⭐⭐hook ต่อเทสต์
test.beforeAll / afterAll ⭐⭐hook ครั้งเดียว
test.step(name, fn) ⭐⭐แบ่งขั้นตอน
test.use(opts) ⭐⭐ตั้ง option ระดับไฟล์ / กลุ่ม
test.skip() / only() / fixme() / fail() / slow() ⭐⭐ควบคุมการรัน
test.setTimeout(ms) ⭐⭐timeout เทสต์นี้
test.extend({...}) ⭐⭐custom fixture
test.describe.configure({ mode }) ⭐'serial', 'parallel', 'default'
test.describe.serial() / parallel() ⭐รันเรียง / ขนาน
test.info() ⭐ข้อมูลเทสต์ปัจจุบัน
testInfo.attach(name, opts) ⭐แนบไฟล์เข้ารายงาน
testInfo.title / project.name / retry / outputPath() ⭐ข้อมูลเทสต์
test.expect / test.mergeTests() / test.mergeExpects() ⭐รวม fixture / expect
defineConfig() ⭐⭐ครอบไฟล์ config
devices['iPhone 13'] ⭐⭐โปรไฟล์อุปกรณ์

Fixture ในตัว: page, context, browser, browserName, request, playwright

ตัวอย่างการแนบไฟล์และอ่านข้อมูลเทสต์:

test('demo', async ({ page }, testInfo) => {
  console.log(testInfo.title, testInfo.retry);        // demo 0
  await testInfo.attach('screenshot', {
    body: await page.screenshot(),
    contentType: 'image/png',
  });
});

19. TypeScript: ฟังก์ชันที่ใช้กับค่าของตัวแปร (เรียงตามความนิยม)

เรียงหัวข้อจากชนิดที่ใช้บ่อยสุดในงานเทสต์: Array, String, Number, Math, Object, JSON, Promise, Date ผลลัพธ์ในคอมเมนต์ // คือค่าที่ได้จริง

ข้อมูลตัวอย่างที่ใช้ทั้งหัวข้อ:

const prices = [49.99, 29.99, 15.99, 15.99, 9.99, 7.99];
const names = ['Backpack', 'Bike Light', 'Bolt T-Shirt'];
19.1 Array (ชนิดที่ใช้บ่อยที่สุด)

ฟังก์ชันที่ต้องรู้ ⭐⭐⭐

prices.length;                           // 6

prices.map(p => p * 2);                  // [99.98, 59.98, 31.98, 31.98, 19.98, 15.98]
prices.filter(p => p > 15);              // [49.99, 29.99, 15.99, 15.99]
prices.find(p => p < 10);                // 9.99 (ตัวแรกที่เจอ ไม่เจอได้ undefined)
prices.findIndex(p => p < 10);           // 4   (ไม่เจอได้ -1)
prices.includes(9.99);                   // true
prices.indexOf(15.99);                   // 2   (ไม่เจอได้ -1)
prices.some(p => p > 40);                // true  (มีอย่างน้อย 1 ตัว)
prices.every(p => p > 5);                // true  (ทุกตัวต้องผ่าน)
prices.reduce((sum, p) => sum + p, 0);   // 129.94 (อาจได้ 129.94000000000003 ให้ใช้ toFixed)
names.join(', ');                        // 'Backpack, Bike Light, Bolt T-Shirt'
names.forEach(n => console.log(n));      // พิมพ์ทีละชื่อ (คืน undefined)

เรียงและตัด ⭐⭐

[3, 1, 2].sort();                        // [1, 2, 3]  (ระวัง: เรียงแบบตัวอักษร)
[10, 9, 1].sort();                       // [1, 10, 9] (ผิดความคาดหวัง)
[10, 9, 1].sort((a, b) => a - b);        // [1, 9, 10] เรียงเลขน้อยไปมาก
[10, 9, 1].sort((a, b) => b - a);        // [10, 9, 1] เรียงมากไปน้อย

[...prices].sort((a, b) => b - a);       // สำเนา: ไม่แก้ array เดิม (sort แก้ array เดิม)

prices.slice(0, 2);                      // [49.99, 29.99] (ไม่แก้ array เดิม)
prices.slice(-2);                        // [9.99, 7.99]
prices.at(-1);                           // 7.99 (ตัวสุดท้าย)
prices[prices.length - 1];               // 7.99 (วิธีเดิม)

เพิ่ม ลบ แก้ ⭐⭐ (แก้ array เดิม)

const a = [1, 2, 3];
a.push(4);          // a = [1, 2, 3, 4], คืนความยาวใหม่ 4
a.pop();            // คืน 4, a = [1, 2, 3]
a.unshift(0);       // เพิ่มหน้า a = [0, 1, 2, 3]
a.shift();          // คืน 0, a = [1, 2, 3]
a.splice(1, 1);     // ลบ 1 ตัวที่ index 1 คืน [2], a = [1, 3]
a.reverse();        // a = [3, 1]

แปลงและรวม ⭐

[1, 2].concat([3]);                      // [1, 2, 3]
[...[1, 2], ...[3, 4]];                  // [1, 2, 3, 4] (spread)
[[1, 2], [3]].flat();                    // [1, 2, 3]
names.flatMap(n => n.split(' '));        // ['Backpack', 'Bike', 'Light', 'Bolt', 'T-Shirt']
Array.from({ length: 3 }, (_, i) => i);  // [0, 1, 2]
Array.isArray(prices);                   // true
new Set(prices);                         // Set { 49.99, 29.99, 15.99, 9.99, 7.99 }
[...new Set(prices)];                    // [49.99, 29.99, 15.99, 9.99, 7.99] (ตัดซ้ำ)
Array(3).fill(0);                        // [0, 0, 0]
prices.entries();                        // iterator ของ [index, value]

Array ในงาน Playwright: ตัวอย่างจริง

const texts = await page.locator('.inventory_item_price').allTextContents();
// ['$49.99', '$29.99', '$15.99', '$15.99', '$9.99', '$7.99']

const nums = texts.map(t => parseFloat(t.replace('$', '')));
// [49.99, 29.99, 15.99, 15.99, 9.99, 7.99]

const sorted = [...nums].sort((a, b) => b - a);
expect(nums).toEqual(sorted);                    // ตรวจเรียงจากมากไปน้อย

const total = nums.reduce((s, n) => s + n, 0);
console.log(total.toFixed(2));                   // '129.94'
19.2 String

ที่ต้องรู้ ⭐⭐⭐

const s = '  Hello Playwright  ';
s.length;                          // 20
s.trim();                          // 'Hello Playwright'
s.trimStart();                     // 'Hello Playwright  '
s.trimEnd();                       // '  Hello Playwright'
s.toLowerCase();                   // '  hello playwright  '
s.toUpperCase();                   // '  HELLO PLAYWRIGHT  '
s.includes('Play');                // true
s.replace('Hello', 'Hi');          // '  Hi Playwright  ' (แทนตัวแรกตัวเดียว)
s.replaceAll('l', 'L');            // '  HeLLo PLaywright  '
s.split(' ');                     // ['', '', 'Hello', 'Playwright', '', '']
s.trim().split(' ');               // ['Hello', 'Playwright']
'$29.99'.replace('$', '');         // '29.99'
'$1,299.50'.replace(/[^0-9.]/g, '');   // '1299.50' (ตัดทุกอย่างที่ไม่ใช่เลข / จุด)

ตรวจและค้น ⭐⭐

const u = 'https://www.saucedemo.com/inventory.html';
u.startsWith('https');             // true
u.endsWith('.html');               // true
u.indexOf('saucedemo');            // 12  (ไม่เจอได้ -1)
u.includes('cart');                // false
u.match(/inventory/);              // ['inventory', index: 26, ...]
u.match(/[0-9]+/g);                // null (ไม่มีตัวเลข)
'a1b22'.match(/[0-9]+/g);          // ['1', '22']
/inventory/.test(u);               // true (regex.test คืน boolean)

ตัดและเติม ⭐⭐

'Backpack'.slice(0, 4);            // 'Back'
'Backpack'.slice(-4);              // 'pack'
'Backpack'.substring(4);           // 'pack'
'Backpack'.charAt(0);              // 'B'
'Backpack'.at(-1);                 // 'k'
'5'.padStart(3, '0');              // '005'
'5'.padEnd(3, '0');                // '500'
'ab'.repeat(3);                    // 'ababab'
'a,b,c'.split(',');                // ['a', 'b', 'c']
'Bolt T-Shirt'.split(' ')[0];      // 'Bolt'

Template literal ⭐⭐⭐

const user = 'standard_user';
const n = 3;
const msg = `ผู้ใช้ ${user} มีสินค้า ${n} ชิ้น รวม ${(n * 9.99).toFixed(2)} บาท`;
// 'ผู้ใช้ standard_user มีสินค้า 3 ชิ้น รวม 29.97 บาท'

await page.locator(`[data-test="add-to-cart-${slug}"]`).click();

อื่น ๆ ⭐

'a'.localeCompare('b');            // -1 (ใช้เรียงสตริง: arr.sort((x, y) => x.localeCompare(y)))
'Ab'.toLowerCase() === 'ab';       // true (เทียบแบบไม่สนตัวพิมพ์)
'abc'.split('').reverse().join('');// 'cba'
[...'abc'];                        // ['a', 'b', 'c']
[...'a1b2'.matchAll(/[0-9]/g)].map(m => m[0]);   // ['1', '2']
19.3 Number และการแปลงชนิด
parseFloat('29.99');               // 29.99
parseFloat('$29.99');              // NaN  <- บั๊กที่เจอในแชท ต้องตัด $ ก่อน
parseFloat('29.99abc');            // 29.99 (อ่านเท่าที่เป็นเลข)
parseInt('42px', 10);              // 42
parseInt('3.9', 10);               // 3
Number('29.99');                   // 29.99
Number('29.99abc');                // NaN (เข้มกว่า parseFloat)
Number('');                        // 0   (ระวัง)
+'12';                             // 12  (ทางลัด)
Number.isNaN(NaN);                 // true
Number.isNaN('abc');               // false (ไม่แปลงชนิด)
isNaN('abc');                      // true  (แปลงชนิดก่อน ไม่แนะนำ)
Number.isInteger(5.0);             // true
Number.isFinite(1 / 0);            // false

(29.99).toFixed(1);                // '30.0'  <- ได้ string ไม่ใช่ number
Number((0.1 + 0.2).toFixed(2));    // 0.3
(1234567.891).toLocaleString();    // '1,234,567.891'
(1234.5).toLocaleString('th-TH', { style: 'currency', currency: 'THB' });  // '฿1,234.50'
(255).toString(16);                // 'ff'
(5).toString();                    // '5'
String(5);                         // '5'
Boolean('');                       // false
!!'abc';                           // true
Number.EPSILON;                    // 2.220446049250313e-16
Number.MAX_SAFE_INTEGER;           // 9007199254740991

ค่า falsy ทั้งหมด: false, 0, -0, 0n, '', null, undefined, NaN (นอกนั้นเป็น truthy รวมถึง '0', [], {})

ปัญหาทศนิยมที่ต้องรู้:

0.1 + 0.2;                         // 0.30000000000000004
0.1 + 0.2 === 0.3;                 // false
expect(0.1 + 0.2).toBeCloseTo(0.3, 2);   // ผ่าน
Math.round((0.1 + 0.2) * 100) / 100;     // 0.3
19.4 Math
Math.max(...prices);               // 49.99  (ต้อง spread: Math.max(prices) ได้ NaN)
Math.min(...prices);               // 7.99
Math.round(2.5);                   // 3
Math.round(-2.5);                  // -2 (ปัดเข้าหา +infinity)
Math.floor(2.9);                   // 2
Math.ceil(2.1);                    // 3
Math.trunc(-2.9);                  // -2 (ตัดทศนิยมทิ้ง)
Math.abs(-5);                      // 5
Math.pow(2, 3);                    // 8   (หรือ 2 ** 3)
Math.sqrt(16);                     // 4
Math.random();                     // 0 ถึงน้อยกว่า 1 เช่น 0.4817...
Math.floor(Math.random() * 100);   // เลขสุ่ม 0 ถึง 99
Math.sign(-3);                     // -1
Math.PI;                           // 3.141592653589793

สุ่มข้อมูลทดสอบ เช่น อีเมลไม่ซ้ำ:

const email = `user${Date.now()}@test.com`;               // 'user1761700000000@test.com'
const rand = Math.floor(Math.random() * 9000) + 1000;     // เลข 4 หลัก 1000 ถึง 9999
19.5 Object
const item = { name: 'Backpack', price: 29.99, tags: ['bag', 'sale'] };

Object.keys(item);                       // ['name', 'price', 'tags']
Object.values(item);                     // ['Backpack', 29.99, ['bag', 'sale']]
Object.entries(item);                    // [['name', 'Backpack'], ['price', 29.99], ['tags', [...]]]
Object.fromEntries([['a', 1], ['b', 2]]);// { a: 1, b: 2 }
Object.assign({}, item, { price: 19 });  // { name: 'Backpack', price: 19, tags: [...] }
Object.hasOwn(item, 'price');            // true
Object.freeze(item);                     // ห้ามแก้ค่า (ใน strict mode แก้แล้ว error)
structuredClone(item);                   // คัดลอกลึก (deep copy)

// Spread: คัดลอกตื้น และแก้บางค่า
const updated = { ...item, price: 19.99 };      // { name: 'Backpack', price: 19.99, tags: [...] }

// Destructuring
const { name, price } = item;            // name = 'Backpack', price = 29.99
const { name: productName } = item;      // ตั้งชื่อใหม่
const [first, second] = ['a', 'b', 'c']; // first = 'a', second = 'b'

// Optional chaining และ nullish
item.discount?.rate;                     // undefined (ไม่ error แม้ discount ไม่มี)
item.discount?.rate ?? 0;                // 0 (ใช้ค่าเริ่มต้นเฉพาะ null / undefined)
item.price || 10;                        // 29.99 (|| ใช้ค่าเริ่มต้นกับ falsy ทั้งหมด รวม 0)
0 || 10;                                 // 10
0 ?? 10;                                 // 0

'price' in item;                         // true
delete (item as any).tags;               // ลบ property

ใช้บ่อยในเทสต์: แปลงข้อมูลผู้ซื้อเป็นก้อนเดียวแล้ววนกรอก

const buyer = { firstName: 'Somchai', lastName: 'Jaidee', postalCode: '10110' };
for (const [key, value] of Object.entries(buyer)) {
  await page.locator(`[data-test="${key}"]`).fill(value);
}
19.6 JSON
const obj = { id: 1, name: 'Somchai' };
JSON.stringify(obj);                     // '{"id":1,"name":"Somchai"}'
JSON.stringify(obj, null, 2);            // จัดย่อหน้า 2 ช่องให้อ่านง่าย
JSON.parse('{"id":1}');                  // { id: 1 }
JSON.parse('not json');                  // throw SyntaxError: Unexpected token 'o'

const copy = JSON.parse(JSON.stringify(obj));    // คัดลอกลึกแบบเก่า (เสียข้อมูล Date, undefined)
19.7 Promise และ async / await (หัวใจของ Playwright)
await page.goto('/');                    // รอจนเสร็จ ลืม await = บั๊กยอดฮิต

const [a, b] = await Promise.all([       // รันพร้อมกัน รอทั้งคู่ (เร็วกว่ารอทีละตัว)
  page.locator('.a').textContent(),
  page.locator('.b').textContent(),
]);

await Promise.allSettled([p1, p2]);      // [{ status: 'fulfilled', value }, { status: 'rejected', reason }]
await Promise.race([p1, p2]);            // เอาตัวที่เสร็จก่อน
await Promise.resolve(5);                // 5

// try / catch / finally
try {
  await page.locator('.x').click({ timeout: 2000 });
} catch (e) {
  console.log((e as Error).message);     // Timeout 2000ms exceeded.
} finally {
  console.log('จบ');
}

// หน่วงเวลา (ใช้น้อยมาก)
await new Promise(r => setTimeout(r, 1000));

ตัวอย่าง Promise.all กับ popup:

const [popup] = await Promise.all([
  page.waitForEvent('popup'),            // เริ่มรอก่อน
  page.getByText('Open').click(),        // แล้วค่อยคลิก (กัน popup มาก่อนที่จะรอ)
]);
19.8 Date
const now = new Date();
Date.now();                              // 1761700000000 (ms นับจาก 1970)
now.toISOString();                       // '2026-10-29T08:30:00.000Z'
now.getFullYear();                       // 2026
now.getMonth();                          // 9  (เดือนเริ่มที่ 0: 9 = ตุลาคม)
now.getDate();                           // 29
now.getDay();                            // 4  (0 = อาทิตย์, 4 = พฤหัสบดี)
now.getHours();                          // 15
now.toLocaleDateString('th-TH');         // '29/10/2569'
now.toLocaleDateString('en-GB');         // '29/10/2026'
new Date('2026-12-31');                  // วันที่จากสตริง
new Date(2026, 0, 15);                   // 15 ม.ค. 2026 (เดือนเริ่มที่ 0)

// ความต่างของวัน
const diffDays = (new Date('2026-12-31').getTime() - now.getTime()) / 86_400_000;

// เพิ่มวัน
const next = new Date(now);
next.setDate(next.getDate() + 7);        // อีก 7 วัน
19.9 RegExp
const re = /^[0-9]{5}$/;
re.test('10110');                        // true
re.test('1011');                         // false
'a1b22'.replace(/[0-9]+/g, '#');         // 'a#b#'
'2026-10-29'.match(/([0-9]{4})-([0-9]{2})-([0-9]{2})/);
// ['2026-10-29', '2026', '10', '29', index: 0, ...]

const m = 'Total: $32.39'.match(/Total: [$]([0-9.]+)/);
Number(m?.[1]);                          // 32.39

new RegExp(`^${prefix}-[0-9]+$`);        // สร้างจากตัวแปร

ธงที่ใช้บ่อย: i ไม่สนตัวพิมพ์, g ทั้งหมด, m หลายบรรทัด

19.10 Map และ Set
const set = new Set([1, 2, 2, 3]);       // Set { 1, 2, 3 }
set.add(4); set.has(2); set.delete(1);   // true สำหรับ has
set.size;                                // 3

const map = new Map<string, number>([['Backpack', 29.99]]);
map.set('Bike Light', 9.99);
map.get('Backpack');                     // 29.99
map.has('X');                            // false
map.size;                                // 2
[...map.entries()];                      // [['Backpack', 29.99], ['Bike Light', 9.99]]
for (const [k, v] of map) console.log(k, v);
19.11 Loop และเงื่อนไข
for (const p of prices) {}               // วนค่า (ใช้ await ภายในได้)
for (const i in prices) {}               // วน index (เป็น string) ไม่แนะนำกับ array
for (let i = 0; i < prices.length - 1; i++) {}   // วนแบบมี index (ระวัง off-by-one)
prices.forEach(async p => { await x(p); });      // ห้าม! ไม่รอ await ให้ใช้ for...of แทน

const grade = score >= 80 ? 'A' : 'B';  // ternary
switch (browserName) { case 'webkit': break; default: break; }
19.12 Operator ที่ควรรู้
Operatorความหมายตัวอย่าง (ผลลัพธ์)
=== / !==เท่ากัน / ไม่เท่ากัน (เข้ม)1 === '1' (false)
==เทียบแบบแปลงชนิด (หลีกเลี่ยง)1 == '1' (true)
?.optional chaininga?.b (undefined ถ้า a ว่าง)
??nullish coalescingnull ?? 5 (5)
|| และ &&หรือ / และ0 || 7 (7)
...spread / rest[...a, ...b]
! และ !!not / แปลงเป็น boolean!!'a' (true)
typeofชนิดของค่าtypeof 5 ('number')
instanceofเป็น instance ของ classx instanceof Date
asบอกชนิดให้ TypeScriptel as HTMLInputElement
! ท้ายตัวแปรบอกว่าไม่ nullvalue!.length
**ยกกำลัง2 ** 3 (8)
19.13 ฟีเจอร์ชนิดข้อมูลของ TypeScript
// ชนิดพื้นฐาน
let n: number = 5;
let s: string = 'a';
let b: boolean = true;
let arr: number[] = [1, 2];
let tup: [string, number] = ['a', 1];
let any1: any;                           // ปิดการตรวจชนิด (หลีกเลี่ยง)
let unk: unknown;                        // ต้องตรวจชนิดก่อนใช้ (ปลอดภัยกว่า any)

// Union และ literal
let status: 'ok' | 'fail' = 'ok';
let id: string | number = 5;

// interface / type
interface Product { name: string; price: number; tags?: string[]; }
type Buyer = { firstName: string; lastName: string; postalCode: string };

// enum
enum Sort { AZ = 'az', ZA = 'za', LOHI = 'lohi', HILO = 'hilo' }
await select.selectOption(Sort.HILO);

// Generics และ ฟังก์ชัน
function first<T>(a: T[]): T | undefined { return a[0]; }
const toNumber = (t: string): number => parseFloat(t.replace(/[^0-9.]/g, ''));

// Utility types
type P1 = Partial<Product>;              // ทุก field เป็น optional
type P2 = Required<Product>;             // ทุก field บังคับ
type P3 = Pick<Product, 'name'>;         // เลือกเฉพาะ field
type P4 = Omit<Product, 'tags'>;         // ตัด field ออก
type P5 = Record<string, number>;        // { [key: string]: number }
type P6 = Readonly<Product>;             // แก้ค่าไม่ได้
type P7 = ReturnType<typeof toNumber>;   // number

// Type guard
if (typeof id === 'string') { id.toUpperCase(); }

ตัวอย่างใช้ interface กับข้อมูลเทสต์:

interface CartItem { name: string; price: number; }
const cart: CartItem[] = [{ name: 'Backpack', price: 29.99 }, { name: 'Bike Light', price: 9.99 }];
const total = cart.reduce((s, i) => s + i.price, 0);   // 39.98
const tax = +(total * 0.08).toFixed(2);                 // 3.2
const grand = +(total + tax).toFixed(2);                // 43.18
19.14 สรุปความต่างที่พลาดบ่อย
กรณีผิดถูก
แปลงราคาNumber('$9.99') (NaN)parseFloat(t.replace('$', ''))
loop เทียบตัวถัดไปi < arr.lengthi < arr.length - 1
max จาก arrayMath.max(arr) (NaN)Math.max(...arr)
เรียงเลขarr.sort()arr.sort((a, b) => a - b)
เรียงแล้วไม่อยากแก้ต้นฉบับarr.sort()[...arr].sort(...)
toFixed ได้ stringtotal.toFixed(2) + 1 ('32.391')+total.toFixed(2) + 1
async ใน forEacharr.forEach(async x => await f(x))for (const x of arr) await f(x)
เทียบ objectexpect(a).toBe(b)expect(a).toEqual(b)
ลืม awaitpage.click()await page.click()
ใช้ || กับเลข 0n || 10n ?? 10
19.15 ตัวอย่างรวม: คำนวณภาษีตามโจทย์ saucedemo
const toNumber = (t: string) => parseFloat(t.replace(/[^0-9.]/g, ''));

const itemTotal = toNumber(await page.locator('[data-test="subtotal-label"]').innerText());
// 'Item total: $59.98' -> 59.98
const tax = toNumber(await page.locator('[data-test="tax-label"]').innerText());
// 'Tax: $4.80' -> 4.8
const total = toNumber(await page.locator('[data-test="total-label"]').innerText());
// 'Total: $64.78' -> 64.78

expect(tax).toBeCloseTo(+(itemTotal * 0.08).toFixed(2), 2);   // 59.98 x 0.08 = 4.7984 -> 4.80
expect(total).toBeCloseTo(itemTotal + tax, 2);                 // 59.98 + 4.80 = 64.78

เอกสารทางการ: https://playwright.dev/docs/api/class-playwright และ https://www.typescriptlang.org/docs/

20. อ่านข้อมูลจาก Excel (Data-driven Testing)

Playwright ไม่มีฟังก์ชันอ่าน Excel ในตัว ต้องใช้ไลบรารีเสริมของ Node.js ตัวเลือกเรียงตามความนิยม:

ไลบรารีความนิยมจุดเด่นติดตั้ง
xlsx (SheetJS)⭐⭐⭐อ่านแบบ sync โค้ดสั้น แปลงเป็น JSON ได้ทันที รองรับ .xlsx .xls .csvnpm i xlsx
exceljs⭐⭐⭐อ่านและเขียนละเอียด (สี, สูตร, สไตล์), ใช้ asyncnpm i exceljs
csv-parse⭐⭐เฉพาะไฟล์ .csv เบาและเร็วnpm i csv-parse
papaparse⭐CSV ใช้ได้ทั้งฝั่ง Node และ browsernpm i papaparse

หมายเหตุ: แพ็กเกจ xlsx บน npm ถูกอัปเดตล่าสุดที่เวอร์ชัน 0.18.5 และมีประกาศช่องโหว่ด้านความปลอดภัย ถ้าเป็นโปรเจกต์จริงควรติดตั้งเวอร์ชันล่าสุดจาก https://cdn.sheetjs.com ตามเอกสารของ SheetJS หรือเลือกใช้ exceljs ส่วนไฟล์ที่เชื่อถือได้ (ไฟล์เทสต์ที่ทีมสร้างเอง) ความเสี่ยงต่ำ

ไฟล์ตัวอย่าง tests/data/users.xlsx (Sheet ชื่อ Login):

usernamepasswordexpected
standard_usersecret_saucesuccess
locked_out_usersecret_sauceerror
problem_usersecret_saucesuccess
20.1 SheetJS (xlsx): ฟังก์ชันที่ใช้บ่อย

ติดตั้ง

npm install xlsx

npm install --save-dev @types/xlsx

npm install --save-dev @types/node

import * as XLSX from 'xlsx';
import path from 'path';

const file = path.join(process.cwd(), 'tests', 'data', 'users.xlsx');

const wb = XLSX.readFile(file);                  // ⭐⭐⭐ อ่านไฟล์ (sync)
console.log(wb.SheetNames);                      // ['Login']

const ws = wb.Sheets['Login'];                   // ⭐⭐⭐ เลือก sheet ตามชื่อ
const ws2 = wb.Sheets[wb.SheetNames[0]];         // ⭐⭐⭐ เลือก sheet แรก

แปลง sheet เป็นข้อมูล ⭐⭐⭐

// 1) เป็น array ของ object (แถวแรกเป็นชื่อ key)
const rows = XLSX.utils.sheet_to_json(ws);
console.log(rows);

ผลลัพธ์:

[
  { username: 'standard_user',   password: 'secret_sauce', expected: 'success' },
  { username: 'locked_out_user', password: 'secret_sauce', expected: 'error' },
  { username: 'problem_user',    password: 'secret_sauce', expected: 'success' }
]
// 2) เป็น array 2 มิติ (รวมแถวหัวตาราง)
const grid = XLSX.utils.sheet_to_json<string[]>(ws, { header: 1 });
console.log(grid);

ผลลัพธ์:

[
  ['username', 'password', 'expected'],
  ['standard_user', 'secret_sauce', 'success'],
  ['locked_out_user', 'secret_sauce', 'error'],
  ['problem_user', 'secret_sauce', 'success']
]

ตัวเลือกของ sheet_to_json ⭐⭐

XLSX.utils.sheet_to_json(ws, {
  header: 1,          // 1 = array 2 มิติ, 'A' = ใช้ตัวอักษรคอลัมน์เป็น key, หรือ ['a','b','c'] ตั้งชื่อเอง
  defval: '',         // ค่าเริ่มต้นของเซลล์ว่าง (ไม่ตั้ง = ช่องว่างจะหายไปจาก object)
  range: 1,           // เริ่มอ่านจากแถวที่ index 1 (ข้ามแถวหัวข้อ) หรือ 'A2:C10'
  raw: false,         // false = ใช้ข้อความตามที่แสดงใน Excel (เช่น '29.99%' แทน 0.2999)
  blankrows: false,   // ข้ามแถวว่าง
});

เซลล์ว่างโดยไม่ตั้ง defval:

{ username: 'standard_user', expected: 'success' }          // password หายไปทั้ง key
{ username: 'standard_user', password: '', expected: 'success' }   // เมื่อตั้ง defval: ''

แปลงเป็นรูปแบบอื่น ⭐

XLSX.utils.sheet_to_csv(ws);          // 'username,password,expected\nstandard_user,...'
XLSX.utils.sheet_to_html(ws);         // '<table>...</table>'
XLSX.utils.sheet_to_txt(ws);          // แยกด้วย tab
XLSX.utils.sheet_to_formulae(ws);     // ['A1="username"', 'B1="password"', ...]

อ่านเซลล์เดียว ⭐⭐

const cell = ws['B2'];
console.log(cell.v);     // 'secret_sauce'  ค่าดิบ
console.log(cell.w);     // 'secret_sauce'  ข้อความที่แสดงผล (formatted)
console.log(cell.t);     // 's'  ชนิด: s=สตริง, n=ตัวเลข, b=boolean, d=วันที่, e=error
console.log(cell.f);     // สูตร (ถ้ามี) เช่น 'SUM(A1:A3)'

console.log(ws['Z99']);  // undefined  (เซลล์ไม่มี ต้องเช็กก่อนอ่าน .v)
console.log(ws['!ref']); // 'A1:C4'  ช่วงข้อมูลทั้งหมด

นับจำนวนแถวและคอลัมน์ ⭐

const range = XLSX.utils.decode_range(ws['!ref']!);
console.log(range.e.r + 1);   // 4  จำนวนแถว (รวมหัวตาราง)
console.log(range.e.c + 1);   // 3  จำนวนคอลัมน์
XLSX.utils.encode_cell({ r: 1, c: 1 });   // 'B2'
XLSX.utils.encode_col(2);                 // 'C'

อ่านวันที่ ⭐⭐ (จุดที่พลาดบ่อย)

const wb2 = XLSX.readFile(file, { cellDates: true });   // แปลงเซลล์วันที่เป็น Date
const data = XLSX.utils.sheet_to_json(wb2.Sheets['Orders']);
// { orderDate: 2026-10-29T00:00:00.000Z }  ถ้าไม่ใส่ cellDates จะได้เลข serial เช่น 46324

อ่านจาก Buffer ⭐

import fs from 'fs';
const wb3 = XLSX.read(fs.readFileSync(file), { type: 'buffer' });

อ่านทุก sheet ⭐

const all: Record<string, unknown[]> = {};
for (const name of wb.SheetNames) {
  all[name] = XLSX.utils.sheet_to_json(wb.Sheets[name]);
}
console.log(Object.keys(all));   // ['Login', 'Checkout']

เขียนไฟล์ ⭐

const out = XLSX.utils.book_new();
const sheet = XLSX.utils.json_to_sheet([{ test: 'login', result: 'pass' }]);
XLSX.utils.book_append_sheet(out, sheet, 'Result');
XLSX.writeFile(out, 'test-results/result.xlsx');
// XLSX.utils.aoa_to_sheet([['a', 'b'], [1, 2]])  สร้างจาก array 2 มิติ
20.2 ExcelJS: ฟังก์ชันที่ใช้บ่อย

ExcelJS เป็น async และนับแถว / คอลัมน์เริ่มที่ 1 (ไม่ใช่ 0)

import ExcelJS from 'exceljs';

const wb = new ExcelJS.Workbook();
await wb.xlsx.readFile('tests/data/users.xlsx');   // ⭐⭐⭐ อ่านไฟล์

const ws = wb.getWorksheet('Login');               // ⭐⭐⭐ เลือกตามชื่อ (ไม่เจอได้ undefined)
const ws1 = wb.worksheets[0];                      // ⭐⭐ เลือกตามลำดับ
console.log(wb.worksheets.map(s => s.name));       // ['Login']

console.log(ws!.rowCount);                         // 4  จำนวนแถว
console.log(ws!.columnCount);                      // 3  จำนวนคอลัมน์
console.log(ws!.actualRowCount);                   // 4  นับเฉพาะแถวที่มีข้อมูล

อ่านเซลล์ ⭐⭐⭐

ws!.getCell('B2').value;           // 'secret_sauce'
ws!.getCell(2, 2).value;           // 'secret_sauce'  (แถว 2 คอลัมน์ 2)
ws!.getRow(2).getCell(1).value;    // 'standard_user'
ws!.getCell('B2').text;            // ข้อความที่แสดงผล (string เสมอ)
ws!.getCell('A1').type;            // ชนิดเซลล์ (ExcelJS.ValueType)
ws!.getCell('D2').formula;         // สูตร
ws!.getCell('D2').result;          // ผลลัพธ์ของสูตร

อ่านแถวและคอลัมน์ ⭐⭐

console.log(ws!.getRow(1).values);
// [ <1 empty item>, 'username', 'password', 'expected' ]   index 0 ว่างเสมอ เพราะนับจาก 1

console.log(ws!.getColumn(1).values);
// [ <1 empty item>, 'username', 'standard_user', 'locked_out_user', 'problem_user' ]

วนอ่านทุกแถว ⭐⭐⭐

type User = { username: string; password: string; expected: string };
const users: User[] = [];

ws!.eachRow((row, rowNumber) => {
  if (rowNumber === 1) return;                 // ข้ามหัวตาราง
  users.push({
    username: String(row.getCell(1).value),
    password: String(row.getCell(2).value),
    expected: String(row.getCell(3).value),
  });
});
console.log(users);

ผลลัพธ์:

[
  { username: 'standard_user', password: 'secret_sauce', expected: 'success' },
  { username: 'locked_out_user', password: 'secret_sauce', expected: 'error' },
  { username: 'problem_user', password: 'secret_sauce', expected: 'success' }
]

eachRow({ includeEmpty: true }, cb) วนรวมแถวว่าง และ ws.eachCell(cb) / row.eachCell(cb) วนระดับเซลล์

เขียนผลกลับ ⭐

ws!.getCell('D2').value = 'PASS';
ws!.addRow(['new_user', 'pw', 'success']);
await wb.xlsx.writeFile('test-results/users-result.xlsx');

ระวังค่าที่เซลล์คืนมา ⭐⭐

cell.value ไม่ได้เป็น string เสมอ อาจเป็น number, Date, object (สูตร, hyperlink, rich text) ควรแปลงก่อนใช้

ws!.getCell('A2').value;   // 'abc'                        string
ws!.getCell('B2').value;   // 123                           number
ws!.getCell('C2').value;   // 2026-10-29T00:00:00.000Z      Date
ws!.getCell('D2').value;   // { formula: 'A1+B1', result: 5 }   สูตร
ws!.getCell('E2').value;   // { text: 'link', hyperlink: 'https://...' }
ws!.getCell('F2').value;   // null  (เซลล์ว่าง)

const text = ws!.getCell('D2').text;       // ใช้ .text เมื่ออยากได้ string ที่ปลอดภัย
20.3 CSV: csv-parse
import fs from 'fs';
import { parse } from 'csv-parse/sync';

const records = parse(fs.readFileSync('tests/data/users.csv'), {
  columns: true,             // ใช้แถวแรกเป็นชื่อ key
  skip_empty_lines: true,
  trim: true,
  bom: true,                 // ตัด BOM ที่ Excel ใส่ตอน Save as CSV UTF-8
});
console.log(records[0]);     // { username: 'standard_user', password: 'secret_sauce', expected: 'success' }

ตัวเลือกอื่น: delimiter: ';' (CSV ที่ใช้ semicolon), from_line: 2, cast: true (แปลงเลขอัตโนมัติ)

20.4 ฟังก์ชัน Node.js ที่ใช้คู่กัน
ฟังก์ชันใช้ทำอะไรตัวอย่าง (ผลลัพธ์)
path.join(...) ⭐⭐⭐ต่อ path ข้าม OSpath.join('tests', 'data', 'a.xlsx') ('tests/data/a.xlsx')
path.resolve(...) ⭐⭐path เต็มpath.resolve('data/a.xlsx') ('C:/proj/data/a.xlsx')
process.cwd() ⭐⭐โฟลเดอร์ที่รันคำสั่ง('C:/Github/test-playwright')
__dirname ⭐⭐โฟลเดอร์ของไฟล์ (CommonJS เท่านั้น)path.join(__dirname, 'data')
fs.existsSync(p) ⭐⭐ไฟล์มีไหม(true)
fs.readFileSync(p) ⭐⭐อ่านไฟล์เป็น Bufferfs.readFileSync(p, 'utf-8') ได้ string
fs.writeFileSync(p, data) ⭐เขียนไฟล์fs.writeFileSync('out.json', json)
fs.mkdirSync(p, { recursive: true }) ⭐สร้างโฟลเดอร์mkdirSync('test-results', { recursive: true })
fs.readdirSync(p) ⭐รายชื่อไฟล์(['users.xlsx', 'items.xlsx'])

ถ้าโปรเจกต์ใช้ ES Module ("type": "module" ใน package.json) จะไม่มี __dirname ให้ใช้ process.cwd() หรือ import.meta.dirname (Node 20.11 ขึ้นไป) แทน

20.5 ใช้ใน Playwright: วนรันหลายชุดข้อมูลจาก Excel

อ่านไฟล์ที่ระดับบนสุดของไฟล์เทสต์ (นอก test()) เพื่อให้ Playwright เห็นรายการเทสต์ตอนเริ่มรัน แล้วสร้างเทสต์ทีละแถว

import { test, expect } from '@playwright/test';
import * as XLSX from 'xlsx';
import path from 'path';

type LoginRow = { username: string; password: string; expected: 'success' | 'error' };

const wb = XLSX.readFile(path.join(process.cwd(), 'tests', 'data', 'users.xlsx'));
const rows = XLSX.utils.sheet_to_json<LoginRow>(wb.Sheets['Login'], { defval: '' });

for (const row of rows) {
  test(`login: ${row.username} -> ${row.expected}`, async ({ page }) => {
    await page.goto('https://www.saucedemo.com');
    await page.getByTestId('username').fill(row.username);
    await page.getByTestId('password').fill(row.password);
    await page.getByTestId('login-button').click();

    if (row.expected === 'success') {
      await expect(page).toHaveURL(/inventory/);
    } else {
      await expect(page.getByTestId('error')).toBeVisible();
    }
  });
}

ผลลัพธ์การรัน:

Running 3 tests using 3 workers

  ✓  1 [chromium] › login.spec.ts:15:7 › login: standard_user -> success (1.9s)
  ✓  2 [chromium] › login.spec.ts:15:7 › login: locked_out_user -> error (1.4s)
  ✓  3 [chromium] › login.spec.ts:15:7 › login: problem_user -> success (2.0s)

  3 passed (4.1s)

ตั้งชื่อเทสต์ให้มีค่าจากแถว (เช่น row.username) ไม่เช่นนั้นชื่อซ้ำกันและ Playwright จะ error ว่า duplicate test title

20.6 ใช้กับ ExcelJS (async)

ExcelJS อ่านแบบ async จึงเรียกที่ระดับบนสุดไม่สะดวก ให้โหลดข้อมูลใน test.beforeAll หรือใช้ไฟล์ตั้งค่า globalSetup แล้วอ่านข้อมูลต่อเทสต์ในรูปแบบนี้

import { test, expect } from '@playwright/test';
import ExcelJS from 'exceljs';

let users: { username: string; password: string }[] = [];

test.beforeAll(async () => {
  const wb = new ExcelJS.Workbook();
  await wb.xlsx.readFile('tests/data/users.xlsx');
  const ws = wb.getWorksheet('Login')!;
  ws.eachRow((row, n) => {
    if (n === 1) return;
    users.push({ username: String(row.getCell(1).value), password: String(row.getCell(2).value) });
  });
});

test('ใช้แถวแรก', async ({ page }) => {
  await page.goto('https://www.saucedemo.com');
  await page.getByTestId('username').fill(users[0].username);
  await page.getByTestId('password').fill(users[0].password);
  await page.getByTestId('login-button').click();
  await expect(page).toHaveURL(/inventory/);
});

ข้อจำกัด: ถ้าต้องการสร้างหลายเทสต์จากหลายแถวแบบหัวข้อ 20.5 ให้ใช้ SheetJS (sync) หรือแปลง Excel เป็น JSON ไว้ล่วงหน้าแล้วอ่านด้วย JSON.parse(fs.readFileSync(...))

20.7 เขียนผลเทสต์กลับ Excel
import * as XLSX from 'xlsx';

const results: { test: string; status: string; time: string }[] = [];

test.afterEach(async ({}, testInfo) => {
  results.push({ test: testInfo.title, status: testInfo.status ?? 'unknown', time: `${testInfo.duration} ms` });
});

test.afterAll(() => {
  const wb = XLSX.utils.book_new();
  XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(results), 'Results');
  XLSX.writeFile(wb, 'test-results/summary.xlsx');
});

ข้อควรระวัง: ถ้ารันหลาย worker พร้อมกัน ตัวแปร results จะแยกกันต่อ worker ให้ตั้ง workers: 1 หรือใช้ custom reporter รวมผล

20.8 ข้อผิดพลาดที่พบบ่อยตอนอ่าน Excel
อาการสาเหตุวิธีแก้
ENOENT: no such file or directorypath ผิด (ขึ้นกับโฟลเดอร์ที่รันคำสั่ง)ใช้ path.join(process.cwd(), ...) และเช็ก fs.existsSync
ค่าเซลล์ว่างทำให้ key หายไม่ตั้ง defvalsheet_to_json(ws, { defval: '' })
ตัวเลขนำหน้า 0 หาย (0812345678 กลายเป็น 812345678)เซลล์เป็นชนิดตัวเลขตั้งคอลัมน์เป็น Text ใน Excel หรืออ่านด้วย raw: false และ String(...).padStart(10, '0')
วันที่ได้เป็นเลข 46324ไม่ได้ใส่ cellDatesXLSX.readFile(f, { cellDates: true })
ExcelJS คืนค่าเป็น { formula, result }เซลล์มีสูตรใช้ cell.result หรือ cell.text
ExcelJS ได้ index เลื่อน 1row.values นับจาก 1ใช้ getCell(n) แทน หรือ .values.slice(1)
Excel เปิดค้างอยู่แล้วอ่านไม่ได้ (Windows)ไฟล์ถูกล็อกปิดไฟล์ก่อนรัน หรือคัดลอกไฟล์ก่อนอ่าน
CSV ภาษาไทยเพี้ยนencoding ไม่ใช่ UTF-8Save as CSV UTF-8 และตั้ง bom: true ใน csv-parse
duplicate test titleชื่อเทสต์จากแต่ละแถวซ้ำกันใส่ค่าที่ไม่ซ้ำ หรือเลขแถวในชื่อ: `row ${i + 1}: ${row.username}`
ลืมแปลงชนิดค่าเลขใน Excel เป็น number แต่ fill() ต้องการ stringfill(String(row.qty))
20.9 ทางเลือกที่ง่ายกว่า: แปลง Excel เป็น JSON ล่วงหน้า

ถ้าไม่อยากพึ่งไลบรารีตอนรันเทสต์ ให้แปลงครั้งเดียวด้วยสคริปต์ แล้วให้เทสต์อ่าน JSON

// scripts/excel-to-json.ts   รัน: npx ts-node scripts/excel-to-json.ts
import * as XLSX from 'xlsx';
import fs from 'fs';

const wb = XLSX.readFile('tests/data/users.xlsx');
const data = XLSX.utils.sheet_to_json(wb.Sheets['Login'], { defval: '' });
fs.writeFileSync('tests/data/users.json', JSON.stringify(data, null, 2));
// ในเทสต์
import users from './data/users.json';   // ต้องเปิด resolveJsonModule ใน tsconfig.json

ข้อดีคือเทสต์เร็วขึ้นและรองรับ import โดยตรง ข้อเสียคือต้องรันสคริปต์แปลงใหม่ทุกครั้งที่แก้ Excel

21. ตัวอย่างโปรเจกต์: Saucedemo E2E แบบ Data-driven (Excel + config.yaml)

ตัวอย่างครบชุดสำหรับโจทย์ E2E Test Suite ของ Saucedemo: อ่านข้อมูลทดสอบจาก Excel 2 sheet (Scenario 1 ซื้อสินค้าสำเร็จ, Scenario 2 กรณี error) และอ่านค่าตั้งต้นจาก config.yaml

หมายเหตุ: ถ้าโจทย์ของคุณระบุให้ใช้ Robot Framework ให้ถามผู้สอนก่อนว่ายอมรับ Playwright + TypeScript หรือไม่
21.1 โครงสร้างโปรเจกต์ และติดตั้ง
saucedemo-project/
├── config.yaml
├── saucedemo_testdata.xlsx      (Sheet: E2E_Purchase, Negative_Cases)
├── playwright.config.ts
├── package.json
├── screenshots/                 (สร้างเองอัตโนมัติ)
└── tests/
    └── saucedemo.spec.ts

ติดตั้งแพ็กเกจ:

npm init playwright@latest
npm i xlsx js-yaml
npm i -D @types/js-yaml
21.2 config.yaml
env:
  url: "https://www.saucedemo.com"
  browser: "chrome"
  headless: false

timeout:
  implicit_wait: "10s"
  explicit_wait: "15s"

default_user:
  standard: "standard_user"
  locked: "locked_out_user"

ค่า explicit_wait: "15s" ถูกแปลงเป็นมิลลิวินาทีด้วย toMs() แล้วตั้งเป็น page.setDefaultTimeout() ใน beforeEach

21.3 playwright.config.ts (สำคัญ: testIdAttribute)
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: {
    testIdAttribute: 'data-test',   // saucedemo ใช้ data-test ไม่ใช่ data-testid
    headless: false,
    channel: 'chrome',
  },
});

Saucedemo ใช้ attribute data-test ถ้าไม่ตั้ง testIdAttribute แล้วเรียก getByTestId('username') จะหาไม่เจอ

21.4 โครงสร้าง Excel (saucedemo_testdata.xlsx)

ทุก sheet ใช้คอลัมน์เดียวกัน: tc_id | run | testcase_name | username | password | items_to_add | first_name | last_name | postal_code | expected_result | note (ตารางด้านล่างละคอลัมน์ note)

Sheet E2E_Purchase

tc_idruntestcase_nameusernamepassworditems_to_addfirst_namelast_namepostal_codeexpected_result
TC01Yซื้อสินค้า 1 ชิ้นstandard_usersecret_sauceSauce Labs BackpackJohnDoe10110Thank you for your order!
TC02Yซื้อหลายชิ้นstandard_usersecret_sauceSauce Labs Backpack,Sauce Labs Bike LightJaneDoe10200Thank you for your order!

Sheet Negative_Cases

tc_idruntestcase_nameusernamepassworditems_to_addfirst_namelast_namepostal_codeexpected_result
NG01Ylocked userlocked_out_usersecret_sauceSorry, this user has been locked out.
NG02Yไม่กรอก First Namestandard_usersecret_sauceSauce Labs BackpackDoe10110Error: First Name is required
NG03Yไม่กรอก Last Namestandard_usersecret_sauceSauce Labs BackpackJohn10110Error: Last Name is required
NG04Yไม่กรอก Postal Codestandard_usersecret_sauceSauce Labs BackpackJohnDoeError: Postal Code is required

ข้อควรระวัง: ชื่อสินค้าต้องตรงกับบนเว็บเป๊ะๆ สินค้าหลายชิ้นคั่นด้วย comma ได้ (โค้ด trim() ให้)

21.5 tests/saucedemo.spec.ts (โค้ดเต็ม)
import { test, expect, Page } from '@playwright/test';
import * as XLSX from 'xlsx';
import * as yaml from 'js-yaml';
import * as fs from 'fs';
import * as path from 'path';

// ---------- โหลด config ----------
const config: any = yaml.load(
  fs.readFileSync(path.join(process.cwd(), 'config.yaml'), 'utf8')
);
const toMs = (s: string) => parseInt(s) * 1000;   // "15s" -> 15000

// ---------- โหลด Excel ----------
type Row = Record<string, string>;
const wb = XLSX.readFile(path.join(process.cwd(), 'saucedemo_testdata.xlsx'));

function loadSheet(name: string): Row[] {
  const rows = XLSX.utils.sheet_to_json<Row>(wb.Sheets[name], {
    defval: '',     // ช่องว่างให้เป็น '' ไม่ใช่ undefined
    raw: false,     // postal_code ที่เป็นตัวเลขจะถูกอ่านเป็น string
  });
  return rows.filter((r) => String(r.run).trim().toUpperCase() === 'Y');
}

// ---------- Helper functions ----------
async function login(page: Page, username: string, password: string) {
  await page.goto(config.env.url);
  await page.getByTestId('username').fill(username);
  await page.getByTestId('password').fill(password);
  await page.getByTestId('login-button').click();
}

async function addItems(page: Page, itemsStr: string) {
  const items = itemsStr.split(',').map((s) => s.trim()).filter(Boolean);
  for (const name of items) {
    const card = page
      .getByTestId('inventory-item')
      .filter({ has: page.getByText(name, { exact: true }) });
    await expect(card, `ไม่พบสินค้า: ${name}`).toHaveCount(1);
    await card.getByRole('button', { name: /add to cart/i }).click();
  }
  return items;
}

async function fillCheckoutInfo(page: Page, r: Row) {
  await page.getByTestId('firstName').fill(r.first_name);
  await page.getByTestId('lastName').fill(r.last_name);
  await page.getByTestId('postalCode').fill(r.postal_code);
  await page.getByTestId('continue').click();
}

test.beforeEach(async ({ page }) => {
  page.setDefaultTimeout(toMs(config.timeout.explicit_wait));
});

// ================= Scenario 1: E2E Purchase =================
test.describe('Scenario 1: E2E Successful Purchase', () => {
  for (const r of loadSheet('E2E_Purchase')) {
    test(`${r.tc_id} - ${r.testcase_name}`, async ({ page }) => {
      await login(page, r.username, r.password);
      await expect(page).toHaveURL(/inventory/);

      const items = await addItems(page, r.items_to_add);
      await expect(page.getByTestId('shopping-cart-badge')).toHaveText(String(items.length));

      await page.getByTestId('shopping-cart-link').click();
      await page.getByTestId('checkout').click();
      await fillCheckoutInfo(page, r);

      // หน้า Overview: ตรวจว่ารายการตรงกับที่เลือก
      await expect(page).toHaveURL(/checkout-step-two/);
      await expect(page.getByTestId('inventory-item-name')).toHaveText(items);
      await page.screenshot({ path: `screenshots/overview-${r.tc_id}.png`, fullPage: true });

      await page.getByTestId('finish').click();
      await expect(page.getByTestId('complete-header')).toHaveText(r.expected_result);
    });
  }
});

// ================= Scenario 2: Negative Cases =================
test.describe('Scenario 2: Negative - Error Validation', () => {
  for (const r of loadSheet('Negative_Cases')) {
    test(`${r.tc_id} - ${r.testcase_name}`, async ({ page }) => {
      await login(page, r.username, r.password);

      // login ไม่ผ่าน (locked user / รหัสผิด) -> เช็ค error ที่หน้า login
      if (!/inventory/.test(page.url()) && (await page.getByTestId('error').isVisible())) {
        await expect(page.getByTestId('error')).toContainText(r.expected_result);
        return;
      }

      // login ผ่าน แต่กรอกข้อมูลชำระเงินไม่ครบ
      if (r.items_to_add) await addItems(page, r.items_to_add);
      await page.getByTestId('shopping-cart-link').click();
      await page.getByTestId('checkout').click();
      await fillCheckoutInfo(page, r);

      await expect(page.getByTestId('error')).toContainText(r.expected_result);
    });
  }
});
21.6 อธิบายส่วนสำคัญของโค้ด
ส่วนทำอะไร
loadSheet(name)อ่าน sheet เป็น array ของ object, กรองเฉพาะแถว run = Y
for (const r of loadSheet(...)) { test(...) }สร้าง test แยกต่อ 1 แถว ชื่อ test มาจาก tc_id และ testcase_name จึงเห็นผลแยกในรายงาน
addItems()แยกสตริงด้วย comma แล้วหาการ์ดสินค้าจากชื่อ กดปุ่ม Add to cart ของการ์ดนั้นๆ
toHaveText(items) (array)เช็คว่ารายชื่อสินค้าในหน้า Overview ตรงกับที่เลือกตามลำดับ
toContainText(r.expected_result)ใช้กับข้อความ error เพราะ Saucedemo ขึ้นต้นด้วย Epic sadface: ฯลฯ จึงเช็คแบบมีข้อความอยู่ภายใน
test.describeจัดกลุ่ม 2 Scenario ให้อ่านรายงานง่าย
21.7 วิธีรัน และตัวอย่างผลลัพธ์
npx playwright test
npx playwright test --ui          # โหมด UI
npx playwright show-report        # เปิดรายงาน HTML

ตัวอย่างผลลัพธ์ (ถ้าข้อมูลใน Excel ถูกต้อง):

Running 6 tests using 1 worker
  ✓  Scenario 1: E2E Successful Purchase › TC01 - ซื้อสินค้า 1 ชิ้น
  ✓  Scenario 1: E2E Successful Purchase › TC02 - ซื้อหลายชิ้น
  ✓  Scenario 2: Negative - Error Validation › NG01 - locked user
  ✓  Scenario 2: Negative - Error Validation › NG02 - ไม่กรอก First Name
  ✓  Scenario 2: Negative - Error Validation › NG03 - ไม่กรอก Last Name
  ✓  Scenario 2: Negative - Error Validation › NG04 - ไม่กรอก Postal Code

  6 passed
21.8 สคริปต์สร้างไฟล์ Excel ตัวอย่าง
// scripts/make-excel.ts   รัน: npx ts-node scripts/make-excel.ts
import * as XLSX from 'xlsx';

const head = ['tc_id','run','testcase_name','username','password','items_to_add',
              'first_name','last_name','postal_code','expected_result','note'];

const e2e = [
  ['TC01','Y','ซื้อสินค้า 1 ชิ้น','standard_user','secret_sauce','Sauce Labs Backpack','John','Doe','10110','Thank you for your order!',''],
  ['TC02','Y','ซื้อหลายชิ้น','standard_user','secret_sauce','Sauce Labs Backpack,Sauce Labs Bike Light','Jane','Doe','10200','Thank you for your order!',''],
];
const neg = [
  ['NG01','Y','locked user','locked_out_user','secret_sauce','','','','','Sorry, this user has been locked out.',''],
  ['NG02','Y','ไม่กรอก First Name','standard_user','secret_sauce','Sauce Labs Backpack','','Doe','10110','Error: First Name is required',''],
  ['NG03','Y','ไม่กรอก Last Name','standard_user','secret_sauce','Sauce Labs Backpack','John','','10110','Error: Last Name is required',''],
  ['NG04','Y','ไม่กรอก Postal Code','standard_user','secret_sauce','Sauce Labs Backpack','John','Doe','','Error: Postal Code is required',''],
];

const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([head, ...e2e]), 'E2E_Purchase');
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet([head, ...neg]), 'Negative_Cases');
XLSX.writeFile(wb, 'saucedemo_testdata.xlsx');

รันครั้งเดียวจะได้ saucedemo_testdata.xlsx พร้อมใช้ (หรือจะสร้างเองใน Excel ก็ได้ ให้ชื่อ sheet ตรงกัน)

22. ตัวอย่าง: E2E Saucedemo แบบไม่ใช้ Test Data (ฮาร์ดโค้ดในเทสต์) เทียบกับการใช้ data-test

ตัวอย่างนี้ไม่อ่านข้อมูลจาก Excel หรือ YAML ค่าทุกอย่าง (user, password, ชื่อผู้ซื้อ) เขียนไว้ในไฟล์เทสต์ และใช้ได้สองแบบคือ เวอร์ชัน A ใช้ attribute data-test กับ เวอร์ชัน B ใช้ getByRole / getByPlaceholder / CSS class

หมายเหตุคำว่า “test-data”: ในหัวข้อนี้ผมตีความว่าหมายถึง attribute data-test ของ Saucedemo (ไม่ใช่ไฟล์ Test Data แบบ Excel ในหัวข้อ 20–21) ถ้าคุณหมายถึงอย่างอื่น แจ้งได้ครับ
22.1 โจทย์ (Requirements)
เปิด https://www.saucedemo.com และ Login ด้วย standard_user
1. Sorting Test: เปลี่ยนเป็น "Price (high to low)" แล้วตรวจว่าชิ้นแรกราคา >= ชิ้นถัดไป (Dynamic Inspection)
2. Multi-Item Selection: เพิ่มสินค้าแพงที่สุดและถูกที่สุดลงตะกร้า แล้วตรวจว่า Badge = 2
3. Cart: ไปหน้า Cart และเช็กว่ามีสินค้า 2 ชิ้นตามที่เลือก
4. Checkout & Calculation: กรอกข้อมูลผู้ซื้อ ที่หน้า Overview ดึง Item Total มาเทียบกับผลรวมราคา 2 ชิ้น
   คำนวณ Tax 8% และเช็กว่า Item Total + Tax = Total
5. Complete & URL: กด Finish, assert URL = https://www.saucedemo.com/checkout-complete.html
   และตรวจข้อความ "Thank you for your order!"
22.2 playwright.config.ts (ต้องมี baseURL สำหรับเวอร์ชัน A)
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  use: {
    baseURL: 'https://www.saucedemo.com',   // ให้ page.goto('/') ใช้ได้
    testIdAttribute: 'data-test',           // ต้องมีถ้าจะใช้ getByTestId()
    headless: false,
  },
});

เวอร์ชัน A ใช้ page.goto('/') จึงต้องตั้ง baseURL ส่วนเวอร์ชัน B ใส่ URL เต็มในโค้ด ไม่ต้องพึ่ง config

22.3 เวอร์ชัน A: ใช้ data-test selector (แยกฟังก์ชันตามหน้าที่ + test.step)
/**
 * Automated UI Test: https://www.saucedemo.com
 * Framework: Playwright Test (TypeScript)
 *
 * เงินทั้งหมดเก็บเป็น "satang/cent" (จำนวนเต็ม) เพื่อเลี่ยงปัญหา floating point
 */
import { test, expect, Page } from '@playwright/test';

const COMPLETE_URL = 'https://www.saucedemo.com/checkout-complete.html';
const USERNAME = 'standard_user';
const PASSWORD = 'secret_sauce';

// ---------------------------------------------------------------------------
// Types & money helpers (หน้าที่: แปลง/จัดรูปแบบเงิน)
// ---------------------------------------------------------------------------
interface Product {
  name: string;
  priceCents: number;
}

const fmt = (cents: number): string => (cents / 100).toFixed(2);

function parseMoneyToCents(text: string): number {
  const m = text.match(/\$\s*([0-9]+(?:\.[0-9]+)?)/);
  expect(m, `ไม่พบตัวเลขราคาในข้อความ: "${text}"`).not.toBeNull();
  return Math.round(parseFloat(m![1]) * 100);
}

// ---------------------------------------------------------------------------
// Reading DOM (หน้าที่: อ่านข้อมูลจากหน้าเว็บจริง)
// ---------------------------------------------------------------------------
async function readProducts(page: Page, containerSelector: string): Promise<Product[]> {
  const items = page.locator(containerSelector);
  await expect(items.first()).toBeVisible();
  const count = await items.count();

  const products: Product[] = [];
  for (let i = 0; i < count; i++) {
    const item = items.nth(i);
    const name = (await item.locator('[data-test="inventory-item-name"]').innerText()).trim();
    const priceText = await item.locator('[data-test="inventory-item-price"]').innerText();
    products.push({ name, priceCents: parseMoneyToCents(priceText) });
  }
  return products;
}

// ---------------------------------------------------------------------------
// Business logic (หน้าที่: หาสินค้าสูงสุด/ต่ำสุด และคำนวณราคา)
// ---------------------------------------------------------------------------
const findMostExpensive = (ps: Product[]): Product =>
  ps.reduce((a, b) => (b.priceCents > a.priceCents ? b : a));

const findCheapest = (ps: Product[]): Product =>
  ps.reduce((a, b) => (b.priceCents < a.priceCents ? b : a));

const sumPrices = (ps: Product[]): number => ps.reduce((s, p) => s + p.priceCents, 0);

/** tax = itemTotal × 8% ปัดเศษ 2 ตำแหน่งแบบ half-up (ใช้เลขจำนวนเต็มล้วน) */
type Rounding = 'half-up' | 'half-even' | 'truncate';
function calcTax(itemTotalCents: number, rounding: Rounding = 'half-up'): number {
  const raw = itemTotalCents * 8; // หน่วย 1/100 ของ cent
  const q = Math.floor(raw / 100);
  const r = raw % 100;
  if (rounding === 'truncate') return q;
  if (rounding === 'half-up') return r >= 50 ? q + 1 : q;
  if (r > 50) return q + 1;
  if (r < 50) return q;
  return q % 2 === 0 ? q : q + 1; // half-even
}

const calcTotal = (itemTotalCents: number, taxCents: number): number => itemTotalCents + taxCents;

// ---------------------------------------------------------------------------
// UI actions
// ---------------------------------------------------------------------------
async function addToCartByName(page: Page, name: string): Promise<void> {
  const item = page
    .locator('.inventory_item')
    .filter({ has: page.getByText(name, { exact: true }) });
  await item.getByRole('button', { name: 'Add to cart' }).click();
  await expect(
    item.getByRole('button', { name: 'Remove' }),
    `กด Add to cart ของ "${name}" แล้วปุ่มต้องเปลี่ยนเป็น Remove`,
  ).toBeVisible();
}

// ---------------------------------------------------------------------------
// The test
// ---------------------------------------------------------------------------
test('SauceDemo: login -> sort -> pick extremes -> checkout -> verify totals', async ({ page }) => {
  // ======================= 1. Login =======================
  await test.step('1. Login ด้วย standard_user', async () => {
    await page.goto('/');
    await page.locator('[data-test="username"]').fill(USERNAME);
    await page.locator('[data-test="password"]').fill(PASSWORD);
    await page.locator('[data-test="login-button"]').click();

    await expect(page, 'Login ต้องเข้าสู่ /inventory.html').toHaveURL(/\/inventory\.html$/);
    await expect(page.locator('[data-test="title"]'), 'หัวข้อหน้าต้องเป็น Products').toHaveText('Products');
  });

  // ======================= 2. Sorting =======================
  await test.step('2. Sort Price (high to low) และตรวจลำดับราคา', async () => {
    await page.locator('[data-test="product-sort-container"]').selectOption('hilo');
    await expect(page.locator('[data-test="active-option"]')).toHaveText('Price (high to low)');

    const products = await readProducts(page, '.inventory_item');
    const prices = products.map((p) => p.priceCents);
    expect(prices.length, 'ต้องมีสินค้าอย่างน้อย 2 ชิ้น').toBeGreaterThanOrEqual(2);

    expect(
      prices[0],
      `ราคาชิ้นที่ 1 (${products[0].name}) ต้อง >= ชิ้นที่ 2 (${products[1].name})`,
    ).toBeGreaterThanOrEqual(prices[1]);

    for (let i = 0; i < prices.length - 1; i++) {
      expect(
        prices[i],
        `ลำดับราคาผิดที่ตำแหน่ง ${i + 1}->${i + 2} (${products[i].name} vs ${products[i + 1].name})`,
      ).toBeGreaterThanOrEqual(prices[i + 1]);
    }
    expect(prices, 'ราคาทั้งหมดต้องเรียงจากมากไปน้อย').toEqual([...prices].sort((a, b) => b - a));
  });

  // ======================= 3. Multi-Item Selection =======================
  let selected: Product[] = [];
  await test.step('3. หาสินค้าแพงสุด/ถูกสุด แล้วเพิ่มลง Cart', async () => {
    const all = await readProducts(page, '.inventory_item');
    const mostExpensive = findMostExpensive(all);
    const cheapest = findCheapest(all);
    console.log(`Most expensive: ${mostExpensive.name} $${fmt(mostExpensive.priceCents)}`);
    console.log(`Cheapest      : ${cheapest.name} $${fmt(cheapest.priceCents)}`);

    expect(mostExpensive.name, 'สินค้าแพงสุดและถูกสุดต้องเป็นคนละชิ้น').not.toBe(cheapest.name);

    selected = [mostExpensive, cheapest];
    for (const p of selected) {
      await addToCartByName(page, p.name);
    }

    await expect(page.locator('[data-test="shopping-cart-badge"]'), 'Cart badge ต้องแสดง 2').toHaveText('2');
  });

  // ======================= 4. Cart Navigation & Validation =======================
  await test.step('4. เปิด Cart และตรวจสินค้า', async () => {
    await page.locator('[data-test="shopping-cart-link"]').click();
    await expect(page).toHaveURL(/\/cart\.html$/);
    await expect(page.locator('.cart_item'), 'Cart ต้องมีสินค้า 2 ชิ้น').toHaveCount(2);

    const cartProducts = await readProducts(page, '.cart_item');
    const byName = (a: Product, b: Product) => a.name.localeCompare(b.name);
    expect(
      [...cartProducts].sort(byName),
      'สินค้าใน Cart ต้องตรงกับสินค้าแพงสุด/ถูกสุดที่เลือก',
    ).toEqual([...selected].sort(byName));
  });

  // ======================= 5. Checkout & Calculation =======================
  await test.step('5.1 Checkout และกรอกข้อมูลผู้ซื้อ', async () => {
    await page.locator('[data-test="checkout"]').click();
    await expect(page).toHaveURL(/\/checkout-step-one\.html$/);
    await page.locator('[data-test="firstName"]').fill('Test');
    await page.locator('[data-test="lastName"]').fill('User');
    await page.locator('[data-test="postalCode"]').fill('10110');
    await page.locator('[data-test="continue"]').click();
    await expect(page).toHaveURL(/\/checkout-step-two\.html$/);
  });

  await test.step('5.2 ตรวจ Item Total / Tax / Total ที่หน้า Overview', async () => {
    const overview = await readProducts(page, '.cart_item');
    expect(overview.length, 'Overview ต้องมีสินค้า 2 ชิ้น').toBe(2);

    // ---- Item Total ----
    const expectedItemTotal = sumPrices(overview);
    expect(
      fmt(expectedItemTotal),
      'ราคาใน Overview ต้องตรงกับราคาที่อ่านจากหน้า Products',
    ).toBe(fmt(sumPrices(selected)));

    const uiItemTotal = parseMoneyToCents(await page.locator('[data-test="subtotal-label"]').innerText());
    expect(fmt(uiItemTotal), 'Item Total บนเว็บต้องตรงกับผลรวมราคาสินค้า').toBe(fmt(expectedItemTotal));

    // ---- Tax 8% ----
    const expectedTax = calcTax(expectedItemTotal, 'half-up');
    const uiTax = parseMoneyToCents(await page.locator('[data-test="tax-label"]').innerText());
    expect(
      fmt(uiTax),
      'Tax บนเว็บต้องเท่ากับ Item Total x 0.08 (ปัด 2 ตำแหน่ง half-up)\n' +
        `  item total = ${fmt(expectedItemTotal)}, raw tax = ${((expectedItemTotal * 8) / 10000).toFixed(4)}\n` +
        `  half-up = ${fmt(expectedTax)}, half-even = ${fmt(calcTax(expectedItemTotal, 'half-even'))}, ` +
        `truncate = ${fmt(calcTax(expectedItemTotal, 'truncate'))}, web = ${fmt(uiTax)}\n` +
        '  => ถ้าไม่ตรง แสดงว่าเว็บอาจใช้กฎปัดเศษต่างจากสูตรที่กำหนด',
    ).toBe(fmt(expectedTax));

    // ---- Total ----
    const expectedTotal = calcTotal(expectedItemTotal, expectedTax);
    const uiTotal = parseMoneyToCents(await page.locator('[data-test="total-label"]').innerText());
    expect(fmt(uiTotal), 'Total บนเว็บต้องเท่ากับ Item Total + Tax').toBe(fmt(expectedTotal));
  });

  // ======================= 6. Complete & URL =======================
  await test.step('6. Finish และตรวจ URL / ข้อความ', async () => {
    await page.locator('[data-test="finish"]').click();
    await expect(page, `URL ต้องเท่ากับ ${COMPLETE_URL}`).toHaveURL(COMPLETE_URL);
    expect(page.url(), 'URL หลัง Finish ไม่ถูกต้อง').toBe(COMPLETE_URL);
    await expect(
      page.getByText('Thank you for your order!'),
      'ต้องแสดงข้อความ Thank you for your order!',
    ).toBeVisible();
  });
});

จุดเด่น: เก็บเงินเป็น cent (เลขจำนวนเต็ม) เลี่ยง floating point, แยก helper อ่าน DOM / คำนวณ / กดปุ่ม, ใช้ test.step ให้รายงานแยกเป็นขั้นตอน และมีข้อความ error ที่ debug ง่าย

22.4 เวอร์ชัน B: ใช้ getByRole / getByPlaceholder / CSS class (ไม่ใช้ data-test)
import { test, expect, Page } from '@playwright/test';

const BASE = 'https://www.saucedemo.com/';
const COMPLETE_URL = 'https://www.saucedemo.com/checkout-complete.html';

interface Product { name: string; priceCents: number }

// แปลง "$29.99" -> 2999 (เก็บเป็น cent เลี่ยง floating point)
const toCents = (text: string): number => {
  const m = text.match(/\$\s*([0-9]+(?:\.[0-9]+)?)/);
  expect(m, `ไม่พบราคาในข้อความ: "${text}"`).not.toBeNull();
  return Math.round(parseFloat(m![1]) * 100);
};
const fmt = (c: number) => (c / 100).toFixed(2);

// อ่านชื่อ+ราคาจากรายการสินค้า (ใช้ CSS class ของเว็บ ไม่ใช้ data-test)
async function readProducts(page: Page, itemSelector: string): Promise<Product[]> {
  const items = page.locator(itemSelector);
  await expect(items.first()).toBeVisible();
  const n = await items.count();
  const list: Product[] = [];
  for (let i = 0; i < n; i++) {
    const it = items.nth(i);
    list.push({
      name: (await it.locator('.inventory_item_name').innerText()).trim(),
      priceCents: toCents(await it.locator('.inventory_item_price').innerText()),
    });
  }
  return list;
}

test('SauceDemo (getByRole + CSS): login -> sort -> extremes -> checkout -> totals', async ({ page }) => {
  // 1. Login
  await page.goto(BASE);
  await page.getByPlaceholder('Username').fill('standard_user');
  await page.getByPlaceholder('Password').fill('secret_sauce');
  await page.getByRole('button', { name: 'Login' }).click();
  await expect(page).toHaveURL(/\/inventory\.html$/);

  // 2. Sorting: Price (high to low)
  await page.locator('select.product_sort_container').selectOption('hilo');
  const sorted = await readProducts(page, '.inventory_item');
  expect(sorted.length).toBeGreaterThanOrEqual(2);
  expect(sorted[0].priceCents, 'ชิ้นแรกต้องแพงกว่าหรือเท่ากับชิ้นที่สอง')
    .toBeGreaterThanOrEqual(sorted[1].priceCents);
  const prices = sorted.map((p) => p.priceCents);
  expect(prices, 'ทั้งหน้าต้องเรียงจากมากไปน้อย').toEqual([...prices].sort((a, b) => b - a));

  // 3. เลือกแพงสุด + ถูกสุด (หลัง sort high->low คือชิ้นแรกกับชิ้นสุดท้าย แต่ใช้ reduce เพื่อไม่พึ่งลำดับ)
  const expensive = sorted.reduce((a, b) => (b.priceCents > a.priceCents ? b : a));
  const cheapest = sorted.reduce((a, b) => (b.priceCents < a.priceCents ? b : a));
  const selected = [expensive, cheapest];

  for (const p of selected) {
    const card = page.locator('.inventory_item').filter({ hasText: p.name });
    await card.getByRole('button', { name: 'Add to cart' }).click();
    await expect(card.getByRole('button', { name: 'Remove' })).toBeVisible();
  }
  await expect(page.locator('.shopping_cart_badge')).toHaveText('2');

  // 4. Cart
  await page.locator('.shopping_cart_link').click();
  await expect(page).toHaveURL(/\/cart\.html$/);
  await expect(page.locator('.cart_item')).toHaveCount(2);
  const inCart = await page.locator('.inventory_item_name').allInnerTexts();
  expect(inCart.sort()).toEqual(selected.map((p) => p.name).sort());

  // 5. Checkout + คำนวณ
  await page.getByRole('button', { name: 'Checkout' }).click();
  await page.getByPlaceholder('First Name').fill('Test');
  await page.getByPlaceholder('Last Name').fill('User');
  await page.getByPlaceholder('Zip/Postal Code').fill('10110');
  await page.getByRole('button', { name: 'Continue' }).click();
  await expect(page).toHaveURL(/\/checkout-step-two\.html$/);

  const overview = await readProducts(page, '.cart_item');
  const sum = overview.reduce((s, p) => s + p.priceCents, 0);

  const uiItemTotal = toCents(await page.locator('.summary_subtotal_label').innerText());
  expect(fmt(uiItemTotal), 'Item Total ต้องเท่ากับผลรวมราคา 2 ชิ้น').toBe(fmt(sum));

  // tax 8% ปัด half-up ด้วยจำนวนเต็ม: floor((sum*8 + 50) / 100)
  const expectedTax = Math.floor((sum * 8 + 50) / 100);
  const uiTax = toCents(await page.locator('.summary_tax_label').innerText());
  expect(fmt(uiTax), 'Tax ต้องเท่ากับ Item Total x 8%').toBe(fmt(expectedTax));

  const uiTotal = toCents(await page.locator('.summary_total_label').innerText());
  expect(fmt(uiTotal), 'Total ต้องเท่ากับ Item Total + Tax').toBe(fmt(sum + expectedTax));

  // 6. Finish
  await page.getByRole('button', { name: 'Finish' }).click();
  await expect(page).toHaveURL(COMPLETE_URL);
  await expect(page.getByRole('heading', { name: 'Thank you for your order!' })).toBeVisible();
});

ลอจิกเหมือนเวอร์ชัน A ต่างกันที่วิธีหา element และเขียนแบบกระชับกว่า (ไม่มี test.step)

22.5 เทียบ selector ทีละจุด
การกระทำใช้ data-test (เวอร์ชัน A)ไม่ใช้ data-test (เวอร์ชัน B)
กรอก usernamelocator('[data-test="username"]')getByPlaceholder('Username')
กด Loginlocator('[data-test="login-button"]')getByRole('button', { name: 'Login' })
เลือก sortlocator('[data-test="product-sort-container"]')locator('select.product_sort_container')
ชื่อ/ราคาสินค้า[data-test="inventory-item-name"] / -price.inventory_item_name / .inventory_item_price
Badge ตะกร้า[data-test="shopping-cart-badge"].shopping_cart_badge
กด Checkout / Continue / Finish[data-test="checkout"] ฯลฯgetByRole('button', { name: 'Checkout' }) ฯลฯ
Item Total / Tax / Total[data-test="subtotal-label"], tax-label, total-label.summary_subtotal_label, .summary_tax_label, .summary_total_label
ข้อความสำเร็จgetByText('Thank you for your order!')getByRole('heading', { name: 'Thank you for your order!' })
22.6 กรณีใช้ data-test กับไม่ใช้ ต่างกันอย่างไร
หัวข้อใช้ data-test ([data-test="..."] หรือ getByTestId)ไม่ใช้ data-test (getByRole / getByPlaceholder / CSS class)
ความเสถียรสูง: ผู้พัฒนาตั้งใจใส่ไว้ให้เทสต์ ไม่เปลี่ยนตามหน้าตาหรือข้อความปานกลาง: getByRole พังเมื่อเปลี่ยนข้อความปุ่ม, CSS class พังเมื่อ refactor สไตล์
ความอ่านง่ายต้องรู้ชื่อ attribute ของแต่ละ elementอ่านเหมือนผู้ใช้จริง เช่น กดปุ่ม Login
ตั้งค่าเพิ่มต้องตั้ง testIdAttribute: 'data-test' ถ้าใช้ getByTestId (ใช้ locator('[data-test=...]') ไม่ต้องตั้ง)ไม่ต้องตั้งอะไร
ตรวจ accessibilityไม่ช่วยgetByRole ช่วยให้รู้ด้วยว่า element มี role และชื่อที่ถูกต้อง
ต้องแก้เทสต์เมื่อ...ทีมพัฒนาลบหรือเปลี่ยนชื่อ data-testข้อความ/โครงสร้าง/class เปลี่ยน
ใช้ได้กับเว็บที่ไม่มี attribute นี้ไม่ได้ ต้องให้ dev เพิ่มให้ได้ทุกเว็บ
Playwright แนะนำแนะนำเมื่อหาด้วย role/text ไม่ได้ผลแนะนำเป็นอันดับแรก (user-facing locator)

สรุปเลือกใช้: หาได้ด้วย getByRole / getByLabel / getByPlaceholder ก็ใช้ก่อน เพราะตรงกับสิ่งที่ผู้ใช้เห็น ส่วน element ที่ไม่มี role หรือข้อความชัด (เช่น ป้ายราคา, badge) ให้ใช้ data-test หรือ CSS class เป็นตัวช่วย

22.7 ตัวอย่างผลลัพธ์เมื่อรัน
Running 1 test using 1 worker
  ✓  SauceDemo: login -> sort -> pick extremes -> checkout -> verify totals

Most expensive: Sauce Labs Fleece Jacket $49.99
Cheapest      : Sauce Labs Onesie $7.99

  1 passed

ตัวอย่างผลลัพธ์ที่ผมเขียนขึ้นเองจากข้อมูลราคาปกติของเว็บ ไม่ใช่ผลจากการรันจริง ราคาและชื่อสินค้าอาจต่างไปถ้าเว็บเปลี่ยน