This guide covers best practices for selectors, waits, test isolation, and environment consistency. Apply these patterns to avoid flaky tests from the start.
Quick Reference
Problem Solution Element timing Wait on UI or API state with expect() Unstable selectors Use data-testid or getByRole Shared state Create and clean up data per test Animations Disable via CSS or prefers-reduced-motion External APIs Mock responses with page.route() Test order dependencies Run tests independently with fullyParallel
Wait for Elements Properly
Playwright auto-waits for elements, but some scenarios require explicit handling.
// Wait for loading indicator to disappear
await expect ( page . locator ( '.loading' )). toBeHidden ();
await page . click ( '.submit-button' );
// Wait for network idle after navigation
await page . goto ( '/dashboard' , { waitUntil: 'networkidle' });
// Wait for specific API response
await Promise . all ([
page . waitForResponse ( resp => resp . url (). includes ( '/api/data' )),
page . click ( '.load-data' )
]);
Avoid networkidle for apps with long polling or analytics. Prefer UI based waits.
Avoid fixed timeouts. They cause slow tests and still fail intermittently:
// Bad: arbitrary delay
await page . waitForTimeout ( 2000 );
// Good: wait for specific condition
await expect ( page . locator ( '.data-loaded' )). toBeVisible ();
Use stable selectors
Fragile selectors are the most common cause of flaky tests. Prefer selectors that survive UI changes:
// Good: test IDs are stable
page . getByTestId ( 'submit-button' )
// Good: accessible roles
page . getByRole ( 'button' , { name: 'Submit' })
// Good: text content
page . getByText ( 'Submit Order' , { exact: true })
// Avoid: CSS classes that may change
page . locator ( '.btn-primary-v2' )
// Avoid: complex CSS paths that break with layout changes
page . locator ( 'div > div:nth-child(2) > button' )
Test IDs give you control over selectors. They do not break when CSS or DOM structure changes.
Isolate test data
Each test creates its own data and cleans up after:
test . beforeEach ( async ({ request }) => {
// Create test user with unique email
const response = await request . post ( '/api/test/users' , {
data: { email: `test- ${ Date . now () } @example.com` }
});
testUser = await response . json ();
});
test . afterEach ( async ({ request }) => {
// Clean up test data
await request . delete ( `/api/test/users/ ${ testUser . id } ` );
});
Avoid relying on shared database state or data from other tests.
Handle animations
Animations cause tests to interact with elements mid-transition. Disable them in test environments:
// playwright.config.ts
import { defineConfig } from '@playwright/test' ;
export default defineConfig ({
use: {
launchOptions: {
args: [ '--force-prefers-reduced-motion' ]
}
}
}) ;
Works only if your app respects the prefers-reduced-motion media query. await page . addStyleTag ({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
`
});
Mock external dependencies
External APIs introduce variability. Mock them for consistent results:
await page . route ( '**/api/products' , route => {
route . fulfill ({
status: 200 ,
contentType: 'application/json' ,
body: JSON . stringify ([{ id: 1 , name: 'Widget' }])
});
});
Mocking gives you consistent data, faster tests, and no dependency on external service health.
Run tests in isolation
Avoid test order dependencies:
// playwright.config.js
export default {
// Randomize test order to catch dependencies
fullyParallel: true ,
// Allow tests to run independently in parallel
workers: process . env . CI ? 4 : undefined
}
Handle Dynamic Content
For timestamps, random IDs, or changing data:
// Use regex for dynamic values
await expect ( page . locator ( '.order-id' )). toHaveText ( /ORD- \d + / );
// Freeze time if needed
await page . addInitScript (() => {
Date . now = () => 1234567890000 ;
});
Retries mask flakiness but improve CI stability:
// playwright.config.js
export default {
retries: process . env . CI ? 2 : 0 ,
// Record traces on retry for debugging
use: {
trace: 'on-first-retry'
}
}
Use TestDino to track tests that need retries. High retry rates indicate tests that need fixing.
Fail on flaky tests
Starting with Playwright v1.52, you can configure the runner to exit with an error if any test is marked flaky.
// playwright.config.ts
export default defineConfig ({
failOnFlakyTests: Boolean ( process . env . CI ) ,
}) ;
Or use the CLI flag:
npx playwright test --fail-on-flaky-tests
This ensures flaky tests block your CI pipeline instead of passing silently.
Use consistent environments
use : {
viewport : { width : 1280 , height : 720 }
}
jobs :
test :
runs-on : ubuntu-latest
container :
image : mcr.microsoft.com/playwright:v1.56.0-jammy
Web fonts load asynchronously. Wait for them or use system fonts in test environments. await page . evaluate ( async () => {
await document . fonts . ready ;
});
Monitor flaky rate
Use TestDino Analytics to track flaky rate over time. Set a team goal and review it regularly. When flaky rate spikes after a deployment, check what changed.
Create tickets
When you find a flaky test in TestDino, create a ticket:
Open the test case
Navigate to the flaky test case in TestDino
Raise an issue
Click Raise Bug or Raise Issue
Select your tool
Select Jira, Linear, Asana, or Monday
Tickets create accountability. They get assigned and fixed.
Need help? Reach out on Discord or email [email protected] .