ข้ามไปที่เนื้อหา

ก่อนอ่านบทนี้ ลองตอบ:

  1. ทำไม assertion ถึงไม่ควรอยู่ใน Page Object method — อธิบายด้วยตัวอย่างสถานการณ์ที่จะเกิดปัญหา?
  2. ถ้า NavBarComponent ปรากฏในหน้า /todos และ /shop ควรออกแบบโครงสร้างอย่างไร — ใช้ Inheritance หรือ Composition และทำไม?

เฉลย:

  1. สถานการณ์ปัญหา: ถ้า loginPage.doLogin() มี expect(sessionBadge).toBeVisible() อยู่ข้างใน test ที่ต้องการตรวจสอบ login fail จะถูก assertion ใน POM throw error ก่อน — test ไม่มีโอกาส assert errorMessage เลย Page Object ควรเป็น action layer ล้วนๆ ให้ test เป็นผู้ assert
  2. ใช้ Composition — สร้าง NavBarComponent แล้วให้ TodosPage และ ShopPage มี readonly navBar: NavBarComponent ใน constructor แต่ละ class Inheritance ผิดเพราะ TodosPage ไม่ใช่ NavBar แค่ "มี" NavBar เท่านั้น

บทที่ 9: Test Organization — Annotations, Tags, Parameterize, Steps

1. วัตถุประสงค์

อ่านผ่านๆ พอ ไม่ต้องเข้าใจทุกคำตอนนี้ — เจอศัพท์แปลกไม่ต้องเครียด เดี๋ยวบทจะพาไปทีละอัน

หลังอ่านบทนี้คุณจะ:

  • ใช้ annotations (test.skip, test.fail, test.fixme, test.slow) ได้อย่างถูกต้อง รู้ว่าแต่ละตัวต่างกันอย่างไร
  • ใช้ tags (@smoke, @regression) เพื่อ filter tests ด้วย --grep และ --grep-invert
  • เขียน parameterized tests ด้วย for...of loop ครอบ test() เพื่อรัน test เดิมกับข้อมูลหลายชุด
  • จัดกลุ่ม actions ด้วย test.step() ให้ Trace Viewer แสดงผลชัดเจนขึ้น
  • เพิ่ม runtime annotations แบบ dynamic ระหว่างที่ test กำลังรัน
  • ปรับ execution mode ของ describe block ด้วย test.describe.configure()
  • อธิบายความแตกต่างระหว่าง annotations กับ tags ได้ชัดเจน

2. ทำไมต้องรู้? (Why)

สมมติ test suite คุณมี 80 tests แล้วเกิดปัญหาสามอย่างพร้อมกัน:

ปัญหาแรก — มี bug ใน feature X ที่ยังแก้ไม่ได้ แต่ tests อื่นๆ ที่ไม่เกี่ยวกันควรรัน pass ต่อไปได้ ถ้าปล่อยให้ tests ของ feature X รันไปก็จะ fail ทุกครั้ง — CI pipeline พัง, ทีมสับสนว่า fail เป็นเรื่องใหม่หรือเรื่องเก่า

ปัญหาที่สอง — login form รองรับ username หลายรูปแบบ (email, phone, username ธรรมดา) ถ้าจะทดสอบทุกรูปแบบต้องเขียน test แยก 3 ตัวที่มี code เหมือนกัน 90% เปลี่ยนแค่ input data

ปัญหาที่สาม — checkout flow มี 8 steps แต่ Trace Viewer แสดงเป็น actions ยาวเหยียด 40 รายการโดยไม่มีกลุ่ม ทีมหาไม่เจอว่า fail ตรงไหน

Playwright มีเครื่องมือแก้ปัญหาทั้งสามนี้โดยตรง: - Annotations แก้ปัญหาแรก — mark tests ที่ skip/broken ได้อย่างมีความหมาย - Loop ครอบ test() แก้ปัญหาที่สอง — parameterize ข้อมูลแยกจาก logic - test.step() แก้ปัญหาที่สาม — จัดกลุ่ม actions ใน Trace Viewer


3. เนื้อหาหลัก

🗺️ อ่านหัวข้อไหน / ข้ามไหน (บทนี้มี 8 หัวข้อย่อย แต่ใช้จริงแค่ 3): - ✅ อ่านจริง: 3.1 Annotations · 3.4 Tags · 3.5 Parameterized — สามอันนี้ใช้ในงานจริงบ่อยสุด - ⏭️ skim (ผ่านตาพอ): 3.3 execution mode · 3.6 test.step() - ❌ ข้ามได้เลย: 3.2 runtime annotations · 3.7 testInfo · 3.8 เทียบ RF

3.1 Annotations — เปลี่ยน Behavior ของ Test

Annotations คือ built-in markers ที่บอก Playwright ว่าควรจัดการกับ test นี้อย่างไร แต่ละตัวมีความหมายต่างกันชัดเจน:


test.skip() — ข้ามไม่รัน

// partial example — see Section 5 for runnable version
// ข้ามเสมอ (ไม่มี condition)
test.skip('feature ยังไม่ implement', async ({ page }) => { ... });

// ข้ามเมื่อ condition เป็น true
test('login บน mobile', async ({ page }) => {
  test.skip(process.env.CI === 'true', 'ไม่รันบน CI pipeline');
  // ...
});

ตาม docs: "Skip a test. Playwright will not run the test past the test.skip() call."

ใช้เมื่อ: feature ยังไม่พร้อม, test ไม่เกี่ยวกับ environment ปัจจุบัน


test.fail() — คาดหวังว่า test จะ fail

// partial example — see Section 5 for runnable version
test('known bug: login ด้วย special chars', async ({ page }) => {
  test.fail(true, 'BUG-789: ยังไม่ fix เรื่อง special chars');
  await page.goto('http://localhost:3000/login');
  await page.fill('[data-testid="input-username"]', 'user@"test"');
  await page.fill('[data-testid="input-password"]', 'pass123');
  await page.click('[data-testid="btn-login"]');
  await expect(page.getByTestId('session-badge')).toBeVisible(); // คาดว่าจะ fail
});

ตาม docs: "Marks a test as 'should fail'. Playwright runs this test and ensures that it is actually failing."

สิ่งสำคัญที่ต้องรู้: ถ้า test กลับมา pass แทนที่จะ fail → Playwright รายงานว่า FAIL เพราะ expectation ที่ตั้งไว้คือมันควรพัง

ใช้เมื่อ: มี known bug ที่ยืนยันได้ และต้องการตรวจสอบว่า bug ยังอยู่หรือถูก fix ไปแล้ว


test.fixme() — รู้ว่าพัง ต้องกลับมาแก้

// partial example — see Section 5 for runnable version
test.fixme('checkout flow หลัง session timeout', async ({ page }) => {
  // Playwright จะ skip test นี้ ไม่รัน
  // แต่ report จะแสดงว่ามี test ที่ mark ว่า fixme อยู่
});

ตาม docs: "Mark a test as 'fixme', with the intention to fix it. Playwright will not run the test past the test.fixme() call."

ความต่างจาก test.skip(): fixme สื่อเจตนาว่า "รู้ว่าพัง ต้องกลับมาแก้" ส่วน skip สื่อว่า "ไม่เกี่ยวข้องในตอนนี้"


test.slow() — เพิ่ม timeout ×3

// partial example — see Section 5 for runnable version
test('export PDF ขนาดใหญ่', async ({ page }) => {
  test.slow(); // timeout เปลี่ยนจาก 30s เป็น 90s
  await page.goto('http://localhost:3000/reports');
  // ...
});

// conditional slow
test('upload ไฟล์ใหญ่', async ({ page }) => {
  test.slow(process.env.SLOW_NETWORK === 'true', 'network throttled env');
  // ...
});

ตาม docs: "Marks a test as 'slow'. Slow test will be given triple the default timeout."

ใช้เมื่อ: test ต้องการเวลานานกว่าปกติ (upload/download ไฟล์ใหญ่, PDF generation, email async)


3.2 Runtime Annotations — เพิ่ม Metadata ขณะรัน

บางครั้งอยากเพิ่มข้อมูลให้ test แบบ dynamic ระหว่างที่กำลังรัน เช่น ผูก test กับ bug ticket — annotations พวกนี้จะปรากฏใน HTML report เพื่อให้ทีมรู้ว่า test ที่ fail นั้นเกี่ยวกับ issue ไหน:

// partial example — see Section 5 for runnable version
test('user profile update', async ({ page }, testInfo) => {
  testInfo.annotations.push({
    type: 'issue',
    description: 'https://jira.example.com/BUG-456'
  });
  testInfo.annotations.push({
    type: 'jira',
    description: 'BUG-456'
  });
  // ...
});

HTML reporter จะแสดง annotations ทั้งหมด ยกเว้นที่ขึ้นต้นด้วย _ (underscore)


3.3 test.describe.configure() — ควบคุม Execution Mode

ปกติ tests ใน describe block เดียวกันรัน parallel ได้ แต่บาง flow มี state ที่ต้องส่งต่อกัน (เช่น login → add to cart → checkout) ในกรณีนั้นต้องบอก Playwright ให้รันตามลำดับ:

// partial example — see Section 5 for runnable version
test.describe.configure({ mode: 'serial' });

test.describe('checkout flow ที่ต้องรันตามลำดับ', () => {
  test('step 1: add to cart', async ({ page }) => { ... });
  test('step 2: fill shipping info', async ({ page }) => { ... });
  test('step 3: confirm payment', async ({ page }) => { ... });
});

Mode ที่มี: - 'default' — tests รันตามลำดับ แต่ถ้า retry จะ retry ตัวนั้นเดี่ยวๆ - 'parallel' — รัน tests ใน describe block นี้พร้อมกันทุกตัว - 'serial' — รันตามลำดับ ถ้าตัวใดตัวหนึ่ง fail → tests ที่เหลือใน block จะ skip ทั้งหมด (เหมาะกับ flow ที่แต่ละ step พึ่งพา step ก่อนหน้า)


3.4 Tags — Category Labels สำหรับ Filter

Tags ใช้สำหรับจัดกลุ่มและ filter tests โดยไม่เปลี่ยน behavior:

Syntax ที่ 1: ใส่ใน title (ทุก version)

// partial example — see Section 5 for runnable version
test('login สำเร็จ @smoke @regression', async ({ page }) => { ... });

Syntax ที่ 2: object parameter (v1.42+)

// partial example — see Section 5 for runnable version
test('login สำเร็จ', { tag: ['@smoke', '@regression'] }, async ({ page }) => {
  // ...
});

test.describe('Auth flows', { tag: '@auth' }, () => {
  test('login', async ({ page }) => { ... });
  test('logout', async ({ page }) => { ... });
  // ทุก test ใน describe นี้ได้รับ @auth tag
});

รัน filter ด้วย CLI:

# รันเฉพาะ tests ที่มี @smoke
npx playwright test --grep @smoke

# รัน tests ที่มีทั้ง @smoke และ @regression (ไม่สนลำดับ)
npx playwright test --grep "(?=.*@smoke)(?=.*@regression)"

# ยกเว้น @slow ทั้งหมด
npx playwright test --grep-invert @slow

# combine: เอา @smoke แต่ยกเว้น @slow
npx playwright test --grep @smoke --grep-invert @slow

หมายเหตุ: @smoke.*@regression จะ match เฉพาะเมื่อ @smoke อยู่ก่อน @regression ในชื่อ test เท่านั้น ถ้าต้องการ match ไม่สนลำดับ (เช่น @regression @smoke) ให้ใช้ lookahead pattern (?=.*@smoke)(?=.*@regression) แทน

Tags ยังแสดงใน HTML report และ available ผ่าน TestCase.tags property สำหรับ custom reporter


3.5 Parameterized Tests — รัน test เดิมกับข้อมูลหลายชุด

เมื่อ test logic เหมือนกันแต่ข้อมูล input/output ต่างกัน อย่า copy code — ให้วนสร้าง test จาก array แทน

⚠️ สำคัญมาก: Playwright ไม่มี test.each() (นั่นเป็น API ของ Jest/Vitest — ถ้าเผลอเรียกใน Playwright จะ error test.each is not a function) วิธีที่ official docs แนะนำคือใช้ for...of loop (หรือ .forEach()) ครอบ test() ไว้ — แต่ละรอบของ loop สร้าง test หนึ่งตัวที่มีชื่อของตัวเอง (source: https://playwright.dev/docs/test-parameterize)

Pattern 1: Array of objects (แนะนำ)

// partial example — see Section 5 for runnable version
const loginScenarios = [
  { username: 'admin',    password: 'admin123', expectedBadge: 'admin' },
  { username: 'testuser', password: 'test123',  expectedBadge: 'testuser' },
];

for (const { username, password, expectedBadge } of loginScenarios) {
  test(`login เป็น ${username} → badge แสดง ${expectedBadge}`, async ({ page }) => {
    await page.goto('http://localhost:3000/login');
    await page.fill('[data-testid="input-username"]', username);
    await page.fill('[data-testid="input-password"]', password);
    await page.click('[data-testid="btn-login"]');
    await expect(page.getByTestId('session-badge')).toContainText(expectedBadge);
  });
}

ใช้ template literal (`login เป็น ${username}...`) ตั้งชื่อ test แต่ละรอบ — ชื่อ ต้องไม่ซ้ำกัน ไม่งั้น Playwright จะ error เรื่อง duplicate title

สำคัญเรื่อง hooks: ถ้าต้องใช้ beforeEach ให้วางไว้ นอก loop ไม่งั้นมันจะถูกประกาศซ้ำทุกรอบ — ตาม docs: "Most of the time you should put beforeEach, beforeAll, afterEach and afterAll hooks outside of forEach, so that hooks are executed just once." ถ้าอยากได้ hook แยกต่อ iteration จริงๆ ให้ครอบด้วย test.describe() แล้ววาง hook ข้างใน (source: https://playwright.dev/docs/test-parameterize)

Pattern 2: parameterize ทั้ง describe block

// partial example — see Section 5 for runnable version
for (const { env, baseUrl } of [
  { env: 'staging', baseUrl: 'https://staging.example.com' },
  { env: 'prod',    baseUrl: 'https://example.com' },
]) {
  test.describe(`Tests บน ${env}`, () => {
    test('homepage โหลดได้', async ({ page }) => {
      await page.goto(baseUrl);
      await expect(page).toHaveTitle(/App/);
    });
  });
}

มาจาก Robot Framework? loop pattern นี้ทำงานเหมือน Test Template + [Template] + Examples (data-driven) — แต่เป็น JavaScript ธรรมดา จึงแทรก logic (เช่น if, คำนวณ expected ระหว่างวน) ได้เต็มที่


⏸ Self-check

หยุดก่อน! เขียนคำตอบลงกระดาษ (พร้อมเหตุผลสั้นๆ 1–2 ประโยคว่าทำไมถึงตอบแบบนั้น) ก่อนเปิดเฉลย:

ทีมของคุณ tag tests ไว้เป็น @smoke, @regression, และ @slow ก่อน deploy ทุกครั้งคุณอยากรันเฉพาะ smoke tests แต่ต้องข้าม tests ที่ช้าออกไป — คุณจะสั่ง CLI ด้วย --grep / --grep-invert อย่างไร และถ้าใช้ --grep smoke เฉยๆ (ไม่มี @) อาจเกิดปัญหาอะไร?

เฉลย ใช้ `--grep @smoke --grep-invert @slow` (เอา `@smoke` แต่ยกเว้น `@slow`) การใช้ `--grep smoke` โดยไม่มี `@` เสี่ยงจะ match กับ test title อื่นที่บังเอิญมีคำว่า "smoke" อยู่ในชื่อ ทำให้รัน tests ที่ไม่ได้ตั้งใจ — ควรใส่ `@` ให้ตรงกับ tag จริงเสมอ - **ถ้าตอบไม่ได้:** กลับไปอ่าน section 3.4 Tags — Category Labels สำหรับ Filter - **ถ้าระบุจุดที่เข้าใจผิดไม่ได้:** อ่าน section ทั้งหมดของบทนี้ใหม่ โดยเน้นตัวอย่าง Beginner และ Intermediate ก่อน

3.6 test.step() — จัดกลุ่ม Actions ใน Trace Viewer

test.step() ทำให้ Trace Viewer แสดง actions เป็น groups ที่มีชื่อ แทนที่จะเห็น list ยาว 40 actions แบบไม่มีโครงสร้าง:

// partial example — see Section 5 for runnable version
test('checkout flow', async ({ page }) => {
  await test.step('Login', async () => {
    await page.goto('http://localhost:3000/login');
    await page.fill('[data-testid="input-username"]', 'admin');
    await page.fill('[data-testid="input-password"]', 'admin123');
    await page.click('[data-testid="btn-login"]');
  });

  await test.step('Add item to cart', async () => {
    await page.goto('http://localhost:3000/shop');
    await page.click('[data-testid="product-1-add"]');
  });

  await test.step('Complete checkout', async () => {
    await page.goto('http://localhost:3000/checkout');
    await page.click('[data-testid="btn-confirm"]');
  });
});

Return valuetest.step() คืนค่าที่ callback return:

// partial example — see Section 5 for runnable version
const authToken = await test.step('Get auth token', async () => {
  await page.goto('http://localhost:3000/login');
  // ... login actions ...
  return await page.evaluate(() => localStorage.getItem('jwt_token'));
});
// ใช้ authToken ต่อได้ใน test

Step timeout — กำหนด timeout แยกต่างหากสำหรับ step:

// partial example — see Section 5 for runnable version
await test.step('Load heavy report', async () => {
  await page.goto('http://localhost:3000/reports/annual');
  await expect(page.getByTestId('report-table')).toBeVisible();
}, { timeout: 15000 }); // step นี้มี timeout 15 วินาทีของตัวเอง


3.7 testInfo Object

testInfo เป็น context ของ test ที่ใช้ได้ใน test body ผ่าน parameter ที่สอง:

// partial example — see Section 5 for runnable version
test('example', async ({ page }, testInfo) => {
  console.log(testInfo.title);      // ชื่อ test
  console.log(testInfo.status);     // 'passed' | 'failed' | 'timedOut' | 'skipped'
  console.log(testInfo.outputDir);  // path สำหรับ save artifacts
  console.log(testInfo.annotations); // array ของ annotations ที่เพิ่มไว้

  // save screenshot เป็น artifact
  const screenshotPath = testInfo.outputPath('screenshot.png');
  await page.screenshot({ path: screenshotPath });
});

3.8 เปรียบเทียบกับ Robot Framework + Selenium

ฟีเจอร์ Robot Framework + Selenium Playwright
Skip test Skip If keyword test.skip(condition, reason)
Skip เสมอ [Tags] skip + custom listener test.skip() ไม่มี condition
Mark expected failure ไม่มี built-in test.fail(condition, reason)
Known broken, skip ไม่มี built-in test.fixme()
Test ช้า, เพิ่ม timeout ตั้ง timeout manual ในทุก test test.slow() ×3 อัตโนมัติ
Tagging [Tags] section ใน test case @tag ใน title หรือ { tag: [...] }
Filter by tag --include / --exclude --grep / --grep-invert
Data-driven tests Test Template + Examples for...of loop ครอบ test()
Group steps ใน report Keyword name ปรากฏใน log test.step() ปรากฏใน Trace Viewer
Runtime metadata Set Variable + Append To Log testInfo.annotations.push()

4. ตัวอย่าง 3 ระดับ

Beginner: Annotations พื้นฐาน

สถานการณ์: เพิ่งเริ่ม test suite ใหม่ มี login test ที่ปกติ, test ที่ต้องการ skip ชั่วคราว, และ test ที่รู้ว่าพัง

// tested: Playwright v1.50+, Node.js 20+
// ไฟล์: tests/auth/login-basic.spec.ts
import { test, expect } from '@playwright/test';

// Test ปกติ — รันและ pass
test('admin login สำเร็จ @smoke', async ({ page }) => {
  await page.goto('http://localhost:3000/login');
  await page.fill('[data-testid="input-username"]', 'admin');
  await page.fill('[data-testid="input-password"]', 'admin123');
  await page.click('[data-testid="btn-login"]');
  await expect(page.getByTestId('session-badge')).toContainText('admin');
});

// test.skip — ไม่รัน เพราะ feature ยังไม่พร้อม
test.skip('SSO login via Google @smoke', async ({ page }) => {
  // จะไม่รัน — Playwright skip ไปเลย
  await page.goto('http://localhost:3000/login/google');
});

// test.skip conditional — skip เฉพาะบน CI
test('login บน mobile viewport', async ({ page }) => {
  test.skip(process.env.CI === 'true', 'Mobile viewport tests ไม่รันบน CI');
  await page.setViewportSize({ width: 375, height: 667 });
  await page.goto('http://localhost:3000/login');
  await expect(page.getByTestId('btn-login')).toBeVisible();
});

// test.fixme — รู้ว่าพัง ต้องกลับมาแก้
test.fixme('login ด้วย username ที่มี emoji 🎭 @BUG-123', async ({ page }) => {
  // Playwright ไม่รัน test นี้ แต่ report แสดงว่ามี fixme อยู่
  await page.goto('http://localhost:3000/login');
  await page.fill('[data-testid="input-username"]', 'user🎭');
});

Output ที่คาดหวัง:

✓ admin login สำเร็จ @smoke (1.3s)
- SSO login via Google @smoke (skipped)
- login บน mobile viewport (skipped)
- login ด้วย username ที่มี emoji 🎭 @BUG-123 (fixme)


Intermediate: Parameterized Tests กับหลาย User Roles

สถานการณ์ใหม่: ระบบมี 4 roles (admin, editor, viewer, guest) แต่ละ role เห็นเมนูต่างกัน — ทดสอบ visibility ของ menu items สำหรับแต่ละ role

// tested: Playwright v1.50+, Node.js 20+
// ไฟล์: tests/rbac/menu-visibility.spec.ts
import { test, expect } from '@playwright/test';

type RoleScenario = {
  role: string;
  password: string;
  canSeeAdminPanel: boolean;
  canSeeTodos: boolean;
};

const roleScenarios: RoleScenario[] = [
  { role: 'admin',    password: 'admin123', canSeeAdminPanel: true,  canSeeTodos: true },
  { role: 'testuser', password: 'test123',  canSeeAdminPanel: false, canSeeTodos: true },
];

test.describe('Menu visibility ตาม role @regression', () => {
  for (const { role, password, canSeeAdminPanel, canSeeTodos } of roleScenarios) {
    test(`${role} เห็น menu ถูกต้อง`, async ({ page }) => {
      // Login
      await page.goto('http://localhost:3000/login');
      await page.fill('[data-testid="input-username"]', role);
      await page.fill('[data-testid="input-password"]', password);
      await page.click('[data-testid="btn-login"]');

      // ยืนยัน session
      await expect(page.getByTestId('session-badge')).toContainText(role);

      // ตรวจสอบ menu items ตาม permission
      const adminPanel = page.getByTestId('nav-admin');
      const todosMenu  = page.getByTestId('nav-todos');

      if (canSeeAdminPanel) {
        await expect(adminPanel).toBeVisible();
      } else {
        await expect(adminPanel).not.toBeVisible();
      }

      if (canSeeTodos) {
        await expect(todosMenu).toBeVisible();
      } else {
        await expect(todosMenu).not.toBeVisible();
      }
    });
  }
});

Output ที่คาดหวัง:

✓ admin เห็น menu ถูกต้อง (1.8s)
✓ testuser เห็น menu ถูกต้อง (1.6s)


Advanced: test.step() + Runtime Annotations ใน Complex Flow

สถานการณ์: ต้องการ test checkout flow ที่มีหลาย steps และแนบ bug ticket กับ report โดยอัตโนมัติ — ใช้ test.step() จัดโครงสร้างให้ Trace Viewer ดู และ testInfo.annotations แนบ metadata

// tested: Playwright v1.50+, Node.js 20+
// ไฟล์: tests/e2e/login-session-flow.spec.ts
import { test, expect } from '@playwright/test';

test('Full session lifecycle: login → verify identity → logout @e2e', async ({ page }, testInfo) => {
  // แนบ issue link เพื่อ traceability ใน HTML report
  testInfo.annotations.push({
    type: 'issue',
    description: 'https://jira.example.com/browse/AUTH-101'
  });
  testInfo.annotations.push({
    type: 'test-plan',
    description: 'TP-2025-Q2-AUTH'
  });

  // ── Step 1: Login ──────────────────────────────────────────────
  const sessionUser = await test.step('Login as admin', async () => {
    await page.goto('http://localhost:3000/login');
    await page.fill('[data-testid="input-username"]', 'admin');
    await page.fill('[data-testid="input-password"]', 'admin123');
    await page.click('[data-testid="btn-login"]');

    // login สำเร็จ → redirect ไป '/' (home) — badge จะเปลี่ยนจาก "Not logged in" เป็น "Logged in as: admin"
    const badge = page.getByTestId('session-badge');
    await expect(badge).toContainText('Logged in as: admin');
    // return ข้อมูล session ให้ steps ถัดไปใช้ต่อ
    return (await badge.textContent()) ?? '';
  });

  // ── Step 2: Navigate to todos and verify session ────────────────
  await test.step(`Navigate to todos — verify session for "${sessionUser}"`, async () => {
    await page.goto('http://localhost:3000/todos');

    // ยืนยัน URL ถูกต้อง
    await expect(page).toHaveURL(/\/todos/);

    // ยืนยันว่า session badge ยังแสดง username ที่ถูกต้องบนหน้า todos ด้วย
    await expect(page.getByTestId('session-badge')).toContainText('admin');
  });

  // ── Step 3: Slow operation — simulate heavy action ──────────────
  await test.step('Load todos list', async () => {
    // ขณะนี้อยู่บนหน้า /todos แล้ว — todo-list ควรปรากฏ
    await expect(page.getByTestId('todo-list')).toBeVisible();
  }, { timeout: 10000 });

  // ── Step 4: Logout ─────────────────────────────────────────────
  await test.step('Logout and verify session cleared', async () => {
    // ถ้า demo app มี logout: await page.click('[data-testid="nav-logout"]');
    // ยืนยันว่า session หายไปหลัง logout
    // await expect(page.getByTestId('session-badge')).not.toBeVisible();
    // สำหรับ demo นี้ navigate ออกไปแทน
    await page.goto('http://localhost:3000/login');
    await expect(page.getByTestId('btn-login')).toBeVisible();
  });

  // เพิ่ม runtime annotation หลังจากรู้ว่า test ผ่าน
  testInfo.annotations.push({
    type: 'result',
    description: `Session user verified: ${sessionUser}`
  });
});

// test.describe.configure — รัน serial เพราะ tests พึ่งพา state กัน
test.describe.configure({ mode: 'serial' });

test.describe('Serial auth tests — ต้องรันตามลำดับ', () => {
  test('ขั้นที่ 1: ตรวจสอบ login page โหลดได้', async ({ page }) => {
    await page.goto('http://localhost:3000/login');
    await expect(page.getByTestId('input-username')).toBeVisible();
    await expect(page.getByTestId('input-password')).toBeVisible();
    await expect(page.getByTestId('btn-login')).toBeVisible();
  });

  test('ขั้นที่ 2: login สำเร็จด้วย valid credential', async ({ page }) => {
    await page.goto('http://localhost:3000/login');
    await page.fill('[data-testid="input-username"]', 'admin');
    await page.fill('[data-testid="input-password"]', 'admin123');
    await page.click('[data-testid="btn-login"]');
    await expect(page.getByTestId('session-badge')).toContainText('admin');
  });

  test('ขั้นที่ 3: verify session badge ถาวรข้ามหน้า', async ({ page }) => {
    // ถ้า test ก่อนหน้า fail → test นี้จะ skip อัตโนมัติ (serial mode)
    await page.goto('http://localhost:3000/login');
    await page.fill('[data-testid="input-username"]', 'admin');
    await page.fill('[data-testid="input-password"]', 'admin123');
    await page.click('[data-testid="btn-login"]');
    await page.goto('http://localhost:3000/todos');
    await expect(page.getByTestId('session-badge')).toBeVisible();
  });
});

Output ที่คาดหวัง (Trace Viewer):

Test: Full session lifecycle...
  ├── Step: Login as admin (0.8s)
  ├── Step: Navigate to todos — verify session for "Logged in as: admin" (0.5s)
  ├── Step: Load todos list (0.4s)
  └── Step: Logout and verify session cleared (0.5s)


5. Common Mistakes

test.skip() โดยไม่มี condition → test skip เสมอโดยไม่ตั้งใจ:

// ❌ ผิด — คนเขียนตั้งใจจะ skip เฉพาะบน Windows
// แต่ไม่ใส่ condition ทำให้ skip บนทุก environment
test('file upload', async ({ page }) => {
  test.skip(); // ← ไม่มี condition = skip เสมอ
  await page.goto('http://localhost:3000/upload');
});

// ✅ ถูก
test('file upload', async ({ page }) => {
  test.skip(process.platform === 'win32', 'File upload ไม่รองรับ Windows');
  await page.goto('http://localhost:3000/upload');
});
เหตุผล: test.skip() โดยไม่มี argument จะ skip test นั้นเสมอไม่มีเงื่อนไข (source: https://playwright.dev/docs/api/class-test#test-skip-1)


❌ สับสน test.fail() กับ test.skip() — เลือกผิดตัว:

// ❌ ผิด — ต้องการ mark ว่าพังแต่ใช้ skip ซ่อนปัญหา
test('checkout หลัง session timeout', async ({ page }) => {
  test.skip(true, 'มี bug อยู่');
  // ← skip ซ่อน bug ไม่แจ้งเตือนถ้า bug ถูก fix
});

// ✅ ถูก — ใช้ fail() เมื่อต้องการยืนยันว่า bug ยังอยู่
test('checkout หลัง session timeout', async ({ page }) => {
  test.fail(true, 'BUG-321: known issue with session expiry');
  // ← ถ้า bug ถูก fix แล้ว test จะ "FAIL" เพราะ pass แทน fail
  // ← ทีมจะรู้ว่าต้องเอา test.fail() ออก
});
เหตุผล: test.fail() ทำหน้าที่เป็น "sentinel" ถ้า bug ถูก fix แล้วแต่ยังมี test.fail() อยู่ Playwright จะรายงาน FAIL — บังคับให้ทีมอัปเดต test (source: https://playwright.dev/docs/api/class-test#test-fail-1)


❌ ใส่ @tag ใน --grep โดยลืม @:

# ❌ ผิด — จะ grep หา string "smoke" ซึ่งอาจเจอ test อื่นที่ไม่ได้ tag
npx playwright test --grep smoke

# ✅ ถูก — grep หา @smoke อย่างชัดเจน
npx playwright test --grep @smoke
เหตุผล: --grep เป็น regex ถ้าไม่ใส่ @ จะ match ทุก test ที่มีคำว่า "smoke" ในชื่อ ซึ่งอาจ include tests ที่ไม่ได้ตั้งใจ (source: https://playwright.dev/docs/test-annotations)


❌ เผลอเรียก test.each() แบบ Jest — Playwright ไม่มี method นี้:

// ❌ ผิด — test.each ไม่มีใน Playwright → error "test.each is not a function"
test.each([
  { username: 'admin', password: 'admin123' },
])('login as %s', async ({ page }, data) => { ... });

// ✅ ถูก — วน for...of ครอบ test() แต่ละรอบสร้าง test แยก
for (const { username, password } of [
  { username: 'admin',    password: 'admin123' },
  { username: 'testuser', password: 'test123' },
]) {
  test(`login as ${username}`, async ({ page }) => { ... });
}
เหตุผล: test.each() เป็น API ของ Jest/Vitest ไม่ใช่ Playwright — คนที่ย้ายมาจาก Jest มักเผลอใช้แล้วงงว่าทำไม error วิธีที่ Playwright รองรับคือวน loop ธรรมดาครอบ test() (source: https://playwright.dev/docs/test-parameterize)


test.describe.configure({ mode: 'serial' }) วางไว้ใน describe block:

// ❌ ผิด — configure ต้องอยู่นอก describe ก่อน หรือเป็น call แรกใน describe
test.describe('my tests', () => {
  test('first', async () => { ... });
  test.describe.configure({ mode: 'serial' }); // ← ช้าไป
});

// ✅ ถูก — configure ก่อน describe หรือเป็น call แรกใน describe callback
test.describe.configure({ mode: 'serial' });
test.describe('my tests', () => {
  test('first', async () => { ... });
  test('second', async () => { ... });
});
เหตุผล: test.describe.configure() ต้องถูกเรียกก่อนที่ tests จะถูก register (source: https://playwright.dev/docs/api/class-test#test-describe-configure)


6. สรุปบท

Annotations, Tags, Parameterize และ Steps เป็นเครื่องมือสำหรับ จัดระเบียบ test suite ให้ maintainable:

  • Annotations เปลี่ยน behavior ของ test: skip (ไม่รัน), fail (คาดว่าจะพัง), fixme (รู้ว่าพัง ต้องแก้), slow (เพิ่ม timeout ×3)
  • Tags เป็น category label สำหรับ filter ด้วย --grep — ต้องขึ้นต้นด้วย @ เสมอ
  • Loop ครอบ test() (Playwright ไม่มี test.each()) แยก test data ออกจาก test logic — ลด duplication และเพิ่ม coverage
  • test.step() จัดกลุ่ม actions ใน Trace Viewer — ทำให้ debug ง่ายขึ้นมากใน complex flows
  • testInfo.annotations เพิ่ม metadata แบบ runtime เช่น bug ticket link

ความแตกต่างที่สำคัญที่สุด: annotation เปลี่ยน behavior, tag เปลี่ยน searchability


คำถาม Retrieval — ลองตอบก่อนดูเฉลย (เขียนเหตุผลสั้นๆ ว่าทำไมถึงตอบแบบนั้นด้วย):

  1. ถ้า test.fail() ถูก mark ไว้แต่ test นั้นกลับ pass จริงๆ — Playwright จะรายงาน status อะไร และทำไมถึงทำแบบนี้?
  2. ถ้าจะรัน login test เดียวกันกับ credential 5 ชุด ใน Playwright ควรเขียนโครงสร้างอย่างไร — และทำไมต้องระวังตำแหน่งของ beforeEach?
  3. ดู code นี้แล้วบอกว่ามีปัญหาอะไร:
    test('upload file @important', async ({ page }) => {
      test.slow();
      test.fixme(true, 'upload broken');
      await page.goto('/upload');
    });
    

เฉลย (คลิกเพื่อดู) **1. test.fail() แล้ว test pass:** Playwright รายงาน **FAILED** เพราะ `test.fail()` หมายความว่า "คาดว่า test นี้จะพัง" การที่ test pass จึงเป็นการ "ผิดคาด" Playwright enforce expectation นี้เพื่อบังคับให้ทีมรู้ว่า bug ถูก fix แล้ว และต้องเอา `test.fail()` ออก ไม่งั้น test suite จะมี dead annotation อยู่ตลอด **2. โครงสร้าง parameterized test:** Playwright **ไม่มี** `test.each()` — ให้วน `for...of` (หรือ `.forEach()`) บน array ของ credential ครอบ `test()` ไว้ แต่ละรอบสร้าง test หนึ่งตัวและตั้งชื่อไม่ซ้ำด้วย template literal เช่น `` test(`login เป็น ${username}`, ...) `` เรื่อง `beforeEach`: ต้องวางไว้ **นอก** loop เพราะถ้าวางในตัว loop มันจะถูก register ซ้ำทุกรอบ ทำให้ hook รันหลายครั้งเกินที่ตั้งใจ ถ้าอยากได้ setup แยกต่อ iteration จริงๆ ให้ครอบ loop ด้วย `test.describe()` แล้ววาง `beforeEach` ข้างใน describe **3. ปัญหาใน code:** มีสองปัญหา: (1) `test.slow()` และ `test.fixme(true, ...)` ทำงานด้วยกัน แต่ `test.fixme()` จะ skip test ทันที ดังนั้น `test.slow()` ไม่มีผลอะไรเลยเพราะ test ไม่ได้รันจริง (2) ถ้าตั้งใจให้ test skip อยู่แล้ว การ mark `@important` ด้วยอาจสื่อสารสับสนกับทีม — ควรเลือกว่า test นี้ "important" หรือ "fixme" ไม่ควรเป็นทั้งสอง --- - **ถ้าตอบข้อเหล่านี้ไม่ได้:** กลับไปอ่าน section 3.1 (Annotations), 3.5 (Parameterized Tests) และ Common Mistakes - **ถ้ายังจับจุดที่เข้าใจผิดไม่ได้:** อ่านบทนี้ใหม่ทั้งบท เน้นตัวอย่าง Beginner และ Intermediate ก่อน