Playwright — Pitfalls¶
OWNER: marije, judith ALSO_USED_BY: ashley (adversarial), antje LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: @playwright/test ^1.58.2
Overview¶
Known failure modes when running Playwright E2E tests in GE projects. Most pitfalls cause flaky tests — tests that pass locally but fail in CI (or vice versa). Agents MUST read this page before writing or debugging E2E tests.
Pitfall 1: Hard Waits (waitForTimeout)¶
The number one cause of flaky tests in every GE project. Hard waits are never the right solution — they are too slow locally and too fast in CI.
ANTI_PATTERN: Using page.waitForTimeout() to wait for anything.
// BROKEN — 2 seconds is enough on your machine, not in CI
await page.getByRole("button", { name: "Save" }).click()
await page.waitForTimeout(2000)
await expect(page.getByText("Saved")).toBeVisible()
FIX: Wait for the actual condition.
// CORRECT — wait for the UI to reflect the action
await page.getByRole("button", { name: "Save" }).click()
await expect(page.getByText("Saved")).toBeVisible() // Auto-retries until visible
CHECK: Any occurrence of waitForTimeout in test code.
THEN: Replace with a specific condition: element visibility, URL change, network response.
THEN: The only acceptable use is debugging (temporarily, with page.pause()).
Pitfall 2: Fragile Selectors¶
CSS selectors and XPath break when developers change class names, restructure HTML, or update styling.
ANTI_PATTERN: CSS class selectors.
// BROKEN — breaks when button styling changes
await page.locator(".btn-primary.submit-form").click()
ANTI_PATTERN: XPath selectors.
// BROKEN — breaks when DOM structure changes
await page.locator("//div[@class='form']/button[2]").click()
ANTI_PATTERN: Nth-child selectors.
// BROKEN — breaks when a new element is added before it
await page.locator("ul > li:nth-child(3)").click()
FIX: Use accessible, role-based locators.
// CORRECT — resilient to styling and structure changes
await page.getByRole("button", { name: "Submit form" }).click()
await page.getByLabel("Email").fill("test@example.com")
await page.getByRole("row", { name: "John Doe" }).getByRole("button", { name: "Edit" }).click()
CHECK: A locator uses CSS classes, IDs, or XPath.
THEN: Refactor to role/label/text locator.
IF: No accessible selector exists.
THEN: Add data-testid attribute to the component and use getByTestId.
Pitfall 3: Tests That Depend on Other Tests¶
Shared state between tests causes ordering-dependent failures. When tests run in parallel, ordering is not guaranteed.
ANTI_PATTERN: Test B relies on data created by Test A.
test("create user", async ({ page }) => {
// Creates a user
})
test("edit user", async ({ page }) => {
// BROKEN in parallel — user might not exist yet
})
FIX: Each test sets up its own data (via API helpers or fixtures).
test("edit user", async ({ page, request }) => {
// Setup: create user via API
await request.post("/api/users", { data: { name: "Test User" } })
// Test: edit the user
await page.goto("/users")
// ...
})
Pitfall 4: CI vs Local Environment Differences¶
Tests pass locally but fail in CI (or vice versa). Common causes:
| Issue | Local | CI |
|---|---|---|
| Speed | Fast machine | Shared runner, slower |
| Screen size | Your monitor | Default 1280x720 |
| Fonts | Installed fonts | System fonts only |
| Timezone | Your timezone | UTC |
| Network | Real APIs available | May be blocked |
FIX: Make tests environment-independent.
// Fix timezone: mock in config
use: {
timezoneId: "Europe/Amsterdam",
locale: "nl-NL",
}
// Fix screen size: set in project config
use: {
viewport: { width: 1280, height: 720 },
}
CHECK: Tests fail in CI but pass locally. THEN: Check trace artifacts for visual differences. THEN: Verify timeouts are sufficient for CI runner speed. THEN: Mock external API dependencies — do not rely on network access in CI.
Pitfall 5: Animations Causing Flakiness¶
Animations delay element states (visible, hidden, enabled). Playwright's auto-wait handles most cases, but CSS animations can cause screenshot mismatches.
FIX: Disable animations in test config.
// playwright.config.ts
use: {
// Disable CSS animations for deterministic tests
launchOptions: {
args: ["--disable-animations"],
},
}
FIX: Or in test setup.
test.beforeEach(async ({ page }) => {
await page.addStyleTag({
content: "*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }",
})
})
Pitfall 6: Navigation Race Conditions¶
Clicking a link and immediately asserting on the next page can race.
ANTI_PATTERN: Asserting before navigation completes.
// POTENTIALLY FLAKY — page might not have navigated yet
await page.getByRole("link", { name: "Settings" }).click()
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
FIX: Wait for URL change when navigation is expected.
await Promise.all([
page.waitForURL("/settings"),
page.getByRole("link", { name: "Settings" }).click(),
])
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible()
CHECK: A click triggers a full page navigation (not SPA route change).
THEN: Use page.waitForURL() in parallel with the click action.
IF: The app uses SPA routing (Next.js App Router).
THEN: expect().toBeVisible() auto-retry is usually sufficient.
Pitfall 7: File Upload / Download Timing¶
File operations are async and OS-dependent.
// CORRECT — wait for download event
const downloadPromise = page.waitForEvent("download")
await page.getByRole("button", { name: "Export CSV" }).click()
const download = await downloadPromise
const path = await download.path()
// CORRECT — file upload
await page.getByLabel("Upload file").setInputFiles("e2e/fixtures/test.csv")
ANTI_PATTERN: Checking for downloaded file on filesystem with a hard wait. FIX: Use Playwright's download event API.
Pitfall 8: Retries Masking Real Bugs¶
Retries (configured as retries: 2 in CI) are intended for transient failures.
A test that consistently needs retries is broken, not flaky.
CHECK: A test is marked as "flaky" in the Playwright report (failed then passed on retry).
THEN: Investigate the root cause — do not accept flaky as normal.
THEN: Common causes: hard waits, shared state, animation timing, slow selectors.
IF: The root cause cannot be fixed immediately.
THEN: Add // TODO: flaky — [ticket URL] comment and quarantine with tag.
Run stable suite: npx playwright test --grep-invert @flaky
Pitfall 9: Memory Leaks in Long Test Suites¶
Large test suites can exhaust browser memory, especially with video recording.
FIX: Configure per-test browser context (Playwright default with fixtures).
FIX: Use video: "retain-on-failure" not video: "on" — avoids recording every test.
FIX: Set workers to limit parallelism in CI: workers: 2 is a good starting point.
Pitfall 10: Stale Authentication State¶
Saved storageState can expire mid-test-run if sessions are short-lived.
CHECK: Authenticated tests fail with 401 errors.
IF: Auth setup project ran successfully.
THEN: Session expired between setup and test execution.
FIX: Ensure test session tokens have long expiry (>1 hour) or re-authenticate in a beforeAll.
Cross-References¶
READ_ALSO: wiki/docs/stack/playwright/index.md READ_ALSO: wiki/docs/stack/playwright/patterns.md READ_ALSO: wiki/docs/stack/playwright/checklist.md READ_ALSO: wiki/docs/stack/vitest/pitfalls.md