The Playwright test runner is failing to correctly identify and interact with elements on the page, leading to intermittent failures because it’s racing against the page’s dynamic rendering.
Common Causes and Fixes for Flaky Playwright Tests
1. Stale Element References
- Diagnosis: Playwright throws an error like
StaleElementReferenceErrororElement is not attached to the DOM. This happens when your test script finds an element, but the DOM changes (e.g., due to an AJAX update or SPA navigation) before Playwright can perform an action on it, invalidating the reference. - Cause 1: AJAX Updates: The element you’re interacting with is replaced or modified by JavaScript after it’s initially found.
- Fix: Use Playwright’s built-in waiting mechanisms. Instead of
page.locator('selector').click(), usepage.locator('selector').click({ timeout: 30000 })to give Playwright more time to wait for the element to be ready and stable. If the element is expected to change, re-query it. - Why it works: Playwright’s default locators and actions automatically wait for elements to be visible, enabled, and stable. Increasing the timeout allows for slower rendering or background updates.
- Fix: Use Playwright’s built-in waiting mechanisms. Instead of
- Cause 2: SPA Re-renders: In Single Page Applications, navigation or state changes can cause components to be unmounted and remounted.
- Fix: Re-query the element after an action that you know triggers a re-render. For example, if clicking a button causes a list to refresh:
page.locator('button#refresh').click() # Re-query the element from the new list page.locator('ul#items li:nth-child(3)').click() - Why it works: By explicitly re-locating the element, you ensure you’re working with a fresh reference to an element that’s present in the current DOM state.
- Fix: Re-query the element after an action that you know triggers a re-render. For example, if clicking a button causes a list to refresh:
- Cause 3: Dynamic IDs or Classes: Elements have attributes (like IDs or class names) that change on each page load or component render.
- Fix: Use more stable selectors. Prefer data attributes (
[data-testid="submit-button"]) or more robust CSS selectors that don’t rely on volatile attributes. If you must use dynamic parts, use Playwright’s text-based locators or attribute selectors with wildcards, but be cautious.# Example: using data-testid page.locator('[data-testid="user-email"]').fill('test@example.com') # Example: using partial attribute match if necessary page.locator('input[id^="username-"]').fill('newuser') - Why it works: Data attributes are intended for test automation and are less likely to change. More general selectors reduce reliance on specific, volatile IDs or classes.
- Fix: Use more stable selectors. Prefer data attributes (
2. Network Interception and Mocking Issues
- Diagnosis: Tests fail because the page relies on network requests that are either not mocked correctly, or the mocked responses don’t match what the application expects. This can manifest as elements not appearing, incorrect data being displayed, or errors in the browser console.
- Cause 1: Incomplete Network Mocks: Your test mocks an API endpoint, but the application makes a secondary request (e.g., for configuration, user data, or a dependency) that isn’t mocked.
- Fix: Use
page.route()to intercept all relevant network requests. Inspect your browser’s network tab during manual testing to identify all outgoing requests. Mock them all or allow specific ones to pass through.page.route('**/api/config', lambda route: route.fulfill(json={'setting': 'value'})) page.route('**/api/users', lambda route: route.abort()) # Example of aborting a request - Why it works: By explicitly defining how every network request should be handled, you eliminate the possibility of unexpected network calls breaking your test.
- Fix: Use
- Cause 2: Incorrect Mocked Response Structures: The JSON or data returned by your mocked API doesn’t match the expected schema of your application.
- Fix: Ensure the
bodyin yourroute.fulfill()call precisely matches the structure and data types expected by the frontend code. This includes array structures, nested objects, and correct data types (strings, numbers, booleans).page.route('**/api/products', lambda route: route.fulfill( status=200, contentType='application/json', body=json.dumps([ {'id': 1, 'name': 'Gadget', 'price': 19.99}, {'id': 2, 'name': 'Widget', 'price': 29.99} ]) )) - Why it works: The frontend code parses the response based on its expected structure. Mismatched types or missing fields will lead to runtime errors in the application’s JavaScript, which Playwright then observes as test failures.
- Fix: Ensure the
3. Page Load and Navigation Timing
- Diagnosis: Tests fail because Playwright tries to interact with elements or assert states before the page or a specific component has fully loaded or rendered. This is common with pages that have many asynchronous operations.
- Cause 1: Slow Initial Page Load: The
page.goto()call completes, but essential JavaScript hasn’t executed yet, or critical DOM elements are still being added.- Fix: Use
page.wait_for_load_state('domcontentloaded')orpage.wait_for_load_state('load')orpage.wait_for_load_state('networkidle')afterpage.goto().networkidleis the most robust but can be slow.page.goto('https://example.com') page.wait_for_load_state('networkidle') # Wait until network is mostly idle page.locator('h1').click() - Why it works: These methods pause execution until specific browser events indicate the page is ready, preventing interaction with incomplete states.
- Fix: Use
- Cause 2: Asynchronous Component Rendering: After the initial page load, certain components or data sections load asynchronously (e.g., lazy-loaded images, dynamic content blocks).
- Fix: Instead of waiting for the entire page, wait for the specific element you need to interact with. Use
page.locator('your-specific-element').wait_for().page.goto('https://example.com/dashboard') # Wait for the specific chart element to appear and be visible page.locator('#user-performance-chart').wait_for(state='visible') page.locator('#user-performance-chart').screenshot() - Why it works: This is more targeted than waiting for the whole page. It ensures the particular part of the UI you’re testing is ready, even if other parts are still loading.
- Fix: Instead of waiting for the entire page, wait for the specific element you need to interact with. Use
4. Browser Context and State Management
- Diagnosis: Tests are intermittently failing because of unexpected state carried over from previous tests, or because the browser context isn’t set up as expected.
- Cause 1: Cross-Test State Leakage: Cookies, local storage, or session storage are not properly cleared between tests, leading to authentication issues or pre-filled forms affecting subsequent tests.
- Fix: Always use a fresh browser context for each test file or even each test case.
# In your test setup (e.g., pytest fixture) browser = playwright.chromium.launch() context = browser.new_context() # Creates a new, clean context page = context.new_page() yield page context.close() browser.close() - Why it works: Each
new_context()starts with a clean slate, ensuring no data from previous browser sessions or tests interferes with the current one.
- Fix: Always use a fresh browser context for each test file or even each test case.
- Cause 2: Unhandled Popups/Dialogs: A modal, alert, or confirmation dialog appears unexpectedly, blocking further interaction.
- Fix: Handle dialogs proactively or set up listeners.
# To accept all dialogs page.on('dialog', lambda dialog: dialog.accept()) # Or to dismiss specific dialogs page.on('dialog', lambda dialog: dialog.dismiss() if 'Are you sure?' in dialog.message else dialog.accept()) # Trigger an action that might cause a dialog page.locator('button#delete').click() - Why it works: The
page.on('dialog', ...)listener intercepts any dialogs that appear, allowing you to programmatically decide whether to accept, dismiss, or interact with them, preventing them from blocking test execution.
- Fix: Handle dialogs proactively or set up listeners.
When you fix these, the next error you’ll likely encounter is a TimeoutError when Playwright can’t find an element that’s dynamically added after network activity has ceased, but before the element itself has rendered.