waitForTimeout(2000) sai theo cả hai hướng: máy CI chậm thì 2s không đủ (fail), máy nhanh thì lãng phí 2s mỗi lần (suite chậm dần). Nó gắn test vào thời gian, trong khi điều bạn cần là gắn vào trạng thái.
Chờ đúng cách — web-first assertion tự retry:
// wrong: fixed sleep
await page.click("#submit")
await page.waitForTimeout(2000)
expect(await page.textContent(".toast")).toBe("Saved")
// right: the assertion polls until it passes or times out
await page.getByRole("button", { name: "Submit" }).click()
await expect(page.getByRole("alert")).toHaveText("Saved")
await expect(page.getByRole("row")).toHaveCount(3)Playwright còn auto-wait trước mỗi hành động: locator phải attached, visible, stable (ngừng animation), enabled và nhận được event thì mới click. Vì vậy phần lớn sleep là thừa. Khi cần chờ đúng một request thì dùng page.waitForResponse(...) chứ không đoán thời gian.
Selector ổn định, theo thứ tự ưu tiên:
1. getByRole(...) với accessible name — bám vào ngữ nghĩa người dùng thấy.
2. getByLabel / getByPlaceholder cho input.
3. getByTestId (data-testid) cho phần tử không có ngữ nghĩa.
4. CSS/XPath là cuối cùng — div > div:nth-child(3) hoặc class hash sinh bởi Tailwind/CSS-in-JS gãy ở lần refactor kế tiếp.
Nguồn flaky còn lại thường là dữ liệu dùng chung: hai test sửa cùng một record. Cho mỗi test tự tạo dữ liệu riêng (email có timestamp) và tránh phụ thuộc thứ tự chạy.