ข้ามไปเนื้อหาหลัก

Category: guide

Playwright E2E Testing — Browser Automation for Modern Web

ทดสอบ UI แบบ end-to-end ด้วย Playwright: เขียน tests, locators, page objects, API mocking, CI integration

· อ่านประมาณ 4 นาที

สารบัญ

Setup

npm init playwright@latest

# หรือ install แยก
npm install --save-dev @playwright/test
npx playwright install  # ดาวน์โหลด browsers

โครงสร้าง

tests/
├── e2e/
│   ├── home.spec.ts
│   └── search.spec.ts
├── fixtures/
│   └── auth.ts
playwright.config.ts

playwright.config.ts

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

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:4321',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
  ],
  webServer: {
    command: 'npm run preview',
    url: 'http://localhost:4321',
    reuseExistingServer: !process.env.CI,
  },
});

Test พื้นฐาน

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

test('homepage has correct title', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveTitle(/Panupong WS/);
});

test('navigation links work', async ({ page }) => {
  await page.goto('/');
  await page.click('a[href="/projects"]');
  await expect(page).toHaveURL('/projects');
  await expect(page.locator('h1')).toContainText('Projects');
});

test('dark mode toggle works', async ({ page }) => {
  await page.goto('/');
  const html = page.locator('html');
  await expect(html).not.toHaveAttribute('data-theme', 'dark');

  await page.click('#theme-toggle');
  await expect(html).toHaveAttribute('data-theme', 'dark');

  await page.click('#theme-toggle');
  await expect(html).not.toHaveAttribute('data-theme', 'dark');
});

Locators — แนะนำให้ใช้

// ✓ Role-based (accessible, stable)
page.getByRole('button', { name: 'Search' })
page.getByRole('heading', { name: 'Projects ล่าสุด' })
page.getByRole('link', { name: 'อ่านเพิ่ม' })

// ✓ Text content
page.getByText('Panupong WS')

// ✓ Label
page.getByLabel('Search projects')

// ✓ Placeholder
page.getByPlaceholder('Search...')

// ✓ Test ID (explicit, ไม่ขึ้นกับ text)
page.getByTestId('hero-stats')

// ✓ CSS selector (เป็น fallback)
page.locator('.section-title')
page.locator('[data-testid="card"]')

// ✗ หลีกเลี่ยง XPath และ nth-of-type เพราะ brittle

Assertions

// Visibility
await expect(element).toBeVisible();
await expect(element).toBeHidden();

// Content
await expect(element).toHaveText('exact text');
await expect(element).toContainText('partial');
await expect(element).toHaveValue('input value');

// Attributes
await expect(element).toHaveAttribute('href', '/projects');
await expect(element).toHaveClass(/is-active/);

// URL
await expect(page).toHaveURL('/projects');
await expect(page).toHaveURL(/projects/);

// Count
await expect(page.locator('.content-card')).toHaveCount(3);

// Soft assertions (ไม่หยุดถ้า fail)
await expect.soft(element).toBeVisible();
await expect.soft(element).toHaveText('...');
// ทั้งหมด fail บน test.end

Page Object Model

// tests/pages/ProjectsPage.ts
import type { Page, Locator } from '@playwright/test';

export class ProjectsPage {
  readonly page: Page;
  readonly heading: Locator;
  readonly cards: Locator;
  readonly filterButtons: Locator;

  constructor(page: Page) {
    this.page = page;
    this.heading = page.getByRole('heading', { level: 1 });
    this.cards = page.locator('.content-card');
    this.filterButtons = page.locator('[data-filter-btn]');
  }

  async goto() {
    await this.page.goto('/projects');
  }

  async filterByStatus(status: 'all' | 'active' | 'completed' | 'archived') {
    await this.filterButtons.filter({ hasText: status }).click();
  }

  async getCardCount() {
    return this.cards.count();
  }
}

// tests/e2e/projects.spec.ts
test('status filter works', async ({ page }) => {
  const projectsPage = new ProjectsPage(page);
  await projectsPage.goto();

  const total = await projectsPage.getCardCount();
  await projectsPage.filterByStatus('active');

  const filtered = await projectsPage.getCardCount();
  expect(filtered).toBeLessThan(total);
});

API Mocking

test('shows error when API fails', async ({ page }) => {
  await page.route('**/api/data', (route) => {
    route.fulfill({
      status: 500,
      contentType: 'application/json',
      body: JSON.stringify({ error: 'Server Error' }),
    });
  });

  await page.goto('/');
  await expect(page.getByText('Something went wrong')).toBeVisible();
});

// Mock specific request
test('loads projects from API', async ({ page }) => {
  await page.route('**/api/projects', (route) => {
    route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: 1, title: 'Mock Project', status: 'active' },
      ]),
    });
  });

  await page.goto('/projects');
  await expect(page.getByText('Mock Project')).toBeVisible();
});

Screenshots และ Visual Comparison

test('homepage screenshot', async ({ page }) => {
  await page.goto('/');
  await expect(page).toHaveScreenshot('home.png', {
    fullPage: true,
    threshold: 0.2,  // 20% pixel difference allowed
  });
});

// Element screenshot
test('card looks correct', async ({ page }) => {
  await page.goto('/projects');
  const card = page.locator('.content-card').first();
  await expect(card).toHaveScreenshot('project-card.png');
});

รัน Tests

# รัน tests ทั้งหมด
npx playwright test

# รัน specific file
npx playwright test home.spec.ts

# รัน เฉพาะ test ที่มีคำว่า "dark mode"
npx playwright test --grep "dark mode"

# รันใน browser จริง (headed)
npx playwright test --headed

# Debug mode — step through test
npx playwright test --debug

# ดู report
npx playwright show-report

# Update screenshots (visual regression)
npx playwright test --update-snapshots

CI (GitHub Actions)

# .github/workflows/playwright.yml
name: Playwright Tests
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20 }
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run build
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30