Có một câu chuyện gần như dev nào làm E2E cũng gặp: bộ test chạy trên máy mình xanh lè, đẩy lên CI thì đỏ. Bấm chạy lại thì xanh. Chạy thêm lần nữa lại đỏ. Kiểu "lúc được lúc không" đó gọi là flaky test, và nó nguy hiểm hơn test fail hẳn — vì sau vài lần, bạn bắt đầu học cách bấm nút chạy lại thay vì đọc lỗi.
Mình từng có một file test khoảng 40 case, trung bình 3 case flaky mỗi lần chạy. Sau khi sửa, tỉ lệ rớt xuống gần như bằng không. Điều đáng nói là phần lớn công sức không nằm ở Playwright, mà nằm ở chỗ hiểu vì sao nó flaky.
Flaky không phải do test, do mình chờ sai chỗ
Danh sách thủ phạm quen thuộc, xếp theo mức độ mình từng gặp:
waitForTimeout— chờ cứng 2 giây. Máy dev đủ nhanh nên qua, CI chậm hơn 2.1 giây là rớt.- Selector bám vào class CSS hoặc chuỗi text dễ đổi — đổi màu nút, sửa chữ là test chết.
- Chờ network sai chỗ: click rồi assert ngay, trong khi request lưu dữ liệu còn đang bay.
- Test này ăn dữ liệu của test kia (share state), nên chạy song song là loạn.
- Animation chưa xong đã assert vị trí phần tử.
Điểm chung: mình đang tự đoán thời điểm mọi thứ sẵn sàng, thay vì để công cụ đo.
Bỏ sleep cứng, dùng web-first assertion
Playwright có cơ chế auto-wait: mỗi action tự chờ phần tử hiện ra, ổn định và nhận được sự kiện. Quan trọng hơn là web-first assertion — expect() trên locator sẽ tự thử lại cho tới khi đúng hoặc hết timeout.
// ❌ dễ flaky: đoán thời gian, assert trên giá trị chụp một lần
await page.click('.btn-submit');
await page.waitForTimeout(2000);
expect(await page.textContent('.toast')).toBe('Lưu thành công');
// ✅ để Playwright tự chờ cho tới khi đúng
await page.getByRole('button', { name: 'Lưu' }).click();
await expect(page.getByRole('status')).toHaveText('Lưu thành công');
Khác biệt nằm ở chỗ: await page.textContent() chụp giá trị đúng một lần rồi so sánh, còn expect(locator) kiểm tra lại liên tục. Cái đầu đỏ vì đọc quá sớm; cái sau chờ tới khi toast thật sự xuất hiện.
Chọn selector bám vào thứ người dùng thấy
getByRole, getByLabel, getByText đi theo accessibility tree — đúng cái mà người dùng thật và screen reader dùng. Đổi CSS, đổi framework, test vẫn sống.
await page.getByLabel('Số điện thoại').fill('0901234567');
await page.getByRole('button', { name: 'Gửi mã' }).click();
await expect(page.getByTestId('otp-error')).toBeVisible();
Với phần tử khó gọi tên theo vai trò, getByTestId chấp nhận được vì nó là hợp đồng rõ ràng giữa test và code. Còn selector kiểu .ant-btn-primary > span:nth-child(2) thì mỗi lần nâng version thư viện là một lần hồi hộp.
Mỗi test tự lo dữ liệu của mình
Test E2E cắn vào database thật, nên mặc định nó không cô lập. Ba mẹo mình dùng:
- Tạo dữ liệu riêng, tên có chèn ID ngẫu nhiên, xong thì xoá — không giẫm lên test khác.
- Login một lần bằng fixture
storageState, đừng login lại ở đầu từng test cho tốn 10 giây. - Chạy song song mặc định, nhưng đừng để hai test cùng sửa một bản ghi.
// fixtures.ts
import { test as base } from '@playwright/test';
export const test = base.extend<{ email: string }>({
email: async ({}, use) => {
await use(`user-${Date.now()}-${Math.random().toString(36).slice(2)}@test.local`);
},
});
Bật trace, đừng đoán mò khi CI đỏ
Test rớt trên CI mà không ngồi trước máy đó thì đọc log rất mệt. Trace của Playwright ghi lại từng bước, kèm snapshot DOM, network và console:
import { defineConfig } from '@playwright/test';
export default defineConfig({
retries: process.env.CI ? 2 : 0,
use: {
trace: 'on-first-retry',
video: 'retain-on-failure',
},
});
Khi CI đỏ, tải file trace.zip về rồi npx playwright show-trace trace.zip là thấy đúng frame lúc phần tử chưa kịp render. Nhiều lần mình phát hiện bug thật của app ngay từ trace, chứ không phải lỗi test.
⚠️ Một lưu ý về retries: nó giúp phân biệt flaky và fail thật, nhưng đừng dùng nó để che flaky. Test retry mới xanh là test cần sửa, không phải test đã ổn.
Chạy nhanh hơn trên CI bằng shard
Khi bộ test phình lên, chạy tuần tự mất cả chục phút. Shard chia test ra nhiều máy chạy song song — Playwright tự lo cân bằng dựa trên lịch sử thời gian chạy:
npx playwright test --shard=1/4
Bốn job trong ma trận của GitHub Actions, mỗi job một shard, tổng thời gian gần như chia tư. Tất nhiên chỉ nên làm sau khi test đã thật sự ổn định, chứ shard một bộ flaky thì chỉ nhân số lần đỏ lên.
Vài điều mình rút ra
- Mọi assert cần phải chờ được. Chụp giá trị rồi so sánh là nguồn flaky số một.
- Không phải thứ gì cũng nên test bằng E2E. Logic tính toán, format tiền, validate form để tầng unit nhanh hơn nhiều — như mình kể ở bài MSW + Vitest.
- Flaky test phải xử lý ngay trong ngày. Để lâu, cả team học cách bỏ qua màu đỏ.
📋 Phụ lục thuật ngữ
| Thuật ngữ | Giải thích ngắn |
|---|---|
| Flaky test | Test lúc pass lúc fail dù code không đổi |
| Auto-waiting | Playwright tự chờ phần tử hiện ra, ổn định và nhận sự kiện trước khi thao tác |
| Web-first assertion | expect(locator) tự thử lại cho tới khi điều kiện đúng hoặc hết timeout |
| Locator | "Con trỏ" lười tới phần tử, chỉ truy vấn khi hành động, không chụp DOM ngay |
| storageState | File lưu cookie/session để tái dùng trạng thái đăng nhập giữa các test |
| Trace viewer | Bản ghi từng bước chạy kèm DOM, network, console để debug sau |
| Shard | Chia bộ test thành nhiều phần chạy song song trên nhiều máy |
Kết
Flaky test giống như tiếng ồn trong nhà: lúc đầu khó chịu, sau đó thành bình thường, rồi tới lúc cần nghe tín hiệu thật thì không nghe được gì. Sửa nó không cần công cụ xịn, chỉ cần thôi đoán thời gian và bắt đầu chờ đúng chỗ.
Bộ E2E của bạn đang flaky ở tầng nào — chờ network, selector, hay dữ liệu dùng chung?