Playwright E2E Testing: Hướng dẫn kiểm thử End-to-End chuyên nghiệp cho ứng dụng Web

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Tại sao E2E testing lại hay bị bỏ qua — và cái giá phải trả

Nhiều team viết unit test khá đầy đủ, nhưng đến khi deploy lên staging thì mới tá hỏa: nút “Thanh toán” không hoạt động trên mobile, hoặc form login bị lỗi trên Safari. Mình từng gặp đúng trường hợp này — project React có coverage unit test 82%, nhưng flow checkout bị broken hoàn toàn trên iPhone vì một CSS z-index che mất nút submit.

Unit test không bắt được lỗi đó vì chúng kiểm tra từng hàm riêng lẻ. E2E test thì khác — nó mô phỏng đúng hành vi người dùng thật: mở trình duyệt, điền form, bấm nút, kiểm tra kết quả.

Trước đây Selenium là lựa chọn phổ biến, nhưng setup rườm rà, WebDriver hay lệch version, và tốc độ thì chậm. Mình từng có suite 80 test Selenium chạy mất 12 phút trên CI. Playwright ra đời năm 2020 từ Microsoft, giải quyết gần như tất cả vấn đề đó: API hiện đại, hỗ trợ Chromium/Firefox/WebKit, chạy song song mặc định — suite tương đương chỉ còn khoảng 2–3 phút.

Bài này mình sẽ đi từ cài đặt đến chạy test thật trên một ứng dụng web — phù hợp nếu bạn đang viết E2E lần đầu.

Cài đặt Playwright

Yêu cầu

  • Node.js 18+ (kiểm tra bằng node -v)
  • npm hoặc yarn

Khởi tạo project

Cách nhanh nhất là dùng lệnh init tích hợp sẵn của Playwright:

npm init playwright@latest

Lệnh này sẽ hỏi vài câu:

  • Dùng TypeScript hay JavaScript? → chọn TypeScript nếu dự án bạn đang dùng TS
  • Thư mục chứa test? → mặc định là tests/
  • Thêm GitHub Actions workflow? → Yes nếu bạn đang dùng CI
  • Cài trình duyệt? → Yes

Sau khi chạy xong, cấu trúc thư mục trông như này:

my-project/
├── tests/
│   └── example.spec.ts
├── playwright.config.ts
├── package.json
└── node_modules/

Cài trình duyệt thủ công (nếu cần)

# Cài tất cả trình duyệt
npx playwright install

# Hoặc chỉ cài Chromium
npx playwright install chromium

Cấu hình chi tiết

File playwright.config.ts

playwright.config.ts là nơi kiểm soát toàn bộ hành vi của test suite — timeout, browser, retry, report. Dưới đây là config mình hay dùng làm baseline:

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

export default defineConfig({
  // Thư mục chứa test files
  testDir: './tests',

  // Chạy tối đa 4 test song song
  workers: 4,

  // Retry 1 lần nếu test fail (giảm flaky)
  retries: 1,

  // Timeout mỗi test: 30 giây
  timeout: 30_000,

  // Report: HTML report + terminal
  reporter: [['html', { open: 'never' }], ['list']],

  use: {
    // URL gốc — dùng baseURL thay vì hardcode
    baseURL: 'http://localhost:3000',

    // Chụp screenshot khi test fail
    screenshot: 'only-on-failure',

    // Ghi video khi test fail
    video: 'retain-on-failure',

    // Bật trace để debug (xem lại từng bước)
    trace: 'on-first-retry',
  },

  // Test trên nhiều trình duyệt
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'mobile-chrome',
      use: { ...devices['Pixel 5'] },
    },
  ],

  // Tự khởi động dev server trước khi test
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Viết test đầu tiên

Giờ viết test thật. Tạo file tests/login.spec.ts:

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

test.describe('Trang đăng nhập', () => {

  test('đăng nhập thành công với thông tin hợp lệ', async ({ page }) => {
    // Điều hướng đến trang login
    await page.goto('/login');

    // Điền form
    await page.fill('[name="email"]', '[email protected]');
    await page.fill('[name="password"]', 'secret123');

    // Bấm nút submit
    await page.click('button[type="submit"]');

    // Kiểm tra redirect về dashboard
    await expect(page).toHaveURL('/dashboard');
    await expect(page.locator('h1')).toContainText('Xin chào');
  });

  test('hiển thị lỗi khi sai mật khẩu', async ({ page }) => {
    await page.goto('/login');
    await page.fill('[name="email"]', '[email protected]');
    await page.fill('[name="password"]', 'saimatkhau');
    await page.click('button[type="submit"]');

    // Kiểm tra thông báo lỗi hiển thị
    await expect(page.locator('.error-message')).toBeVisible();
    await expect(page.locator('.error-message')).toContainText('Mật khẩu không đúng');
  });

});

Dùng Page Object Model để tái sử dụng code

Viết nhiều test hơn, bạn sẽ thấy mình copy-paste selector khắp nơi. Page Object Model (POM) giải quyết điều này bằng cách gom tất cả selector của một trang vào một class riêng:

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

export class LoginPage {
  readonly page: Page;
  readonly emailInput: Locator;
  readonly passwordInput: Locator;
  readonly submitButton: Locator;
  readonly errorMessage: Locator;

  constructor(page: Page) {
    this.page = page;
    this.emailInput = page.locator('[name="email"]');
    this.passwordInput = page.locator('[name="password"]');
    this.submitButton = page.locator('button[type="submit"]');
    this.errorMessage = page.locator('.error-message');
  }

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

  async login(email: string, password: string) {
    await this.emailInput.fill(email);
    await this.passwordInput.fill(password);
    await this.submitButton.click();
  }
}

Dùng trong test:

// tests/login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';

test('đăng nhập thành công', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('[email protected]', 'secret123');
  await expect(page).toHaveURL('/dashboard');
});

Chạy test và theo dõi kết quả

Các lệnh chạy test cơ bản

# Chạy tất cả test
npx playwright test

# Chạy 1 file cụ thể
npx playwright test tests/login.spec.ts

# Chạy chỉ trên Chrome
npx playwright test --project=chromium

# Chạy ở chế độ có giao diện (xem trình duyệt)
npx playwright test --headed

# Chạy 1 test theo tên
npx playwright test -g "đăng nhập thành công"

# Mở HTML report
npx playwright show-report

Dùng Trace Viewer để debug

Test fail mà không biết bắt đầu debug từ đâu? Đó là lúc Trace Viewer phát huy tác dụng. Nó ghi lại từng bước như video, kèm theo DOM snapshot, network request và console log tại mỗi thời điểm — không cần thêm một dòng console.log nào.

# Chạy với trace bật
npx playwright test --trace on

# Mở trace của lần chạy gần nhất
npx playwright show-trace test-results/login-chromium/trace.zip

Quy trình debug của mình: chạy test fail → mở trace → xem ảnh chụp màn hình tại bước lỗi → check DOM xem selector có đúng không. Nhanh hơn nhiều so với chạy lại test nhiều lần với log rải rác.

Tích hợp CI/CD với GitHub Actions

Nếu bạn chọn “Yes” khi init, Playwright đã tạo sẵn file workflow. Nhưng đây là template mình hay dùng:

# .github/workflows/playwright.yml
name: Playwright E2E Tests

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run E2E tests
        run: npx playwright test
        env:
          CI: true

      - name: Upload test report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

Một vài tip từ thực tế

Về selector, thứ tự ưu tiên của mình là: data-testid → ARIA role → label → text → CSS class. CSS class và XPath dễ vỡ khi refactor UI — đổi tên class một cái là hàng chục test đỏ ngay.

// Tốt — ổn định, không bị ảnh hưởng bởi styling
await page.click('[data-testid="submit-btn"]');
await page.click('role=button[name="Đăng nhập"]');

// Tránh — dễ bị vỡ
await page.click('.btn.btn-primary.login-form__submit');

Network mocking cũng là thứ Playwright làm tốt hơn nhiều tool khác. Cần test màn hình “Không có sản phẩm” mà không muốn xóa data thật? Dùng page.route() để intercept:

// Mock API trả về danh sách rỗng
await page.route('**/api/products', route => {
  route.fulfill({
    status: 200,
    contentType: 'application/json',
    body: JSON.stringify([]),
  });
});

await page.goto('/products');
await expect(page.locator('.empty-state')).toBeVisible();

Một công cụ khác mình hay dùng khi chuẩn bị test data: toolcraft.app — format JSON response từ API nhanh để xem cấu trúc trước khi viết assertion, tiện hơn nhiều so với cài extension trình duyệt.

Tổng kết

Playwright phù hợp từ project nhỏ đến lớn — API đơn giản, debug tốt, chạy nhanh. Bắt đầu từ việc viết test cho các luồng quan trọng nhất (đăng nhập, thanh toán, submit form) rồi mở rộng dần.

So với Selenium: không cần WebDriver riêng, selector API thông minh hơn nhiều, và Trace Viewer không có đối thủ khi debug. Đang dùng Cypress? Playwright đáng thử vì hỗ trợ nhiều browser hơn và chạy song song mặc định — không cần cấu hình thêm.

Share: