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.

