Skip to content

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.

// Quarantine flaky test with tag
test("known flaky test @flaky", async ({ page }) => {
  // ...
})

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