Category: guide
Playwright E2E Testing — Browser Automation for Modern Web
ทดสอบ UI แบบ end-to-end ด้วย Playwright: เขียน tests, locators, page objects, API mocking, CI integration
สารบัญ
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