Flaky test fixes: Playwright best practices explained
Your test suite passes locally but fails in CI. Your colleague runs the same test twice and gets different results. You've seen this pattern enough to know the problem isn't your application—it's your tests. Flaky tests erode trust in automation, waste hours in troubleshooting, and create friction in development workflows. Let's walk through the root causes of test flakiness and the concrete practices that eliminate them.
The cost of flaky tests
When tests fail randomly, teams stop trusting them. Engineers disable assertions, increase timeout values, and eventually stop running the suite altogether. The irony is sharp: the tool meant to catch bugs becomes noise. After 100 days of Playwright work, you've likely encountered flakiness. The good news is that most causes fall into a small set of patterns, and fixing them follows repeatable techniques.
Hard-coded waits — Replace waitForTimeout with web-first assertions
The most common source of flakiness is a hard-coded timeout:
await page.waitForTimeout(2000); // ❌ Flaky
await page.click('button');
This approach fails on slow infrastructure, passes too quickly on fast hardware, and teaches Playwright nothing about what you're actually waiting for. Two seconds might be enough on Tuesday but not on Wednesday when the database is slow.
Replace every waitForTimeout with a web-first assertion that targets the actual condition:
// Wait for the button to be visible and clickable
await page.locator('button').waitFor({ state: 'visible' });
await page.click('button');
// Or in one call with built-in wait
await page.click('button'); // Already waits for actionability
When you click, type, or fill, Playwright automatically waits for the element to be in an actionable state. For custom conditions, use waitFor:
// Wait for dynamic content to load
await page.locator('[data-testid="user-card"]').waitFor({ state: 'visible' });
// Wait for text to appear
await page.locator('text=Order confirmed').waitFor({ state: 'visible' });
// Wait for a count to change
await expect(page.locator('li')).toHaveCount(5);
The pattern is consistent: describe what must happen, not how long to wait. Playwright handles the timing. This single change eliminates the majority of flakiness in most test suites.
Shared data — Give every test its own data
Tests that share state create hidden dependencies. One test leaves data in the database, the next test depends on it (whether intentionally or by accident), and a third test deletes it. Run the suite in a different order, and everything breaks.
In a real e-commerce scenario, you might have:
// ❌ Shared data — flaky
let userId = 0;
test('create user', async ({ page }) => {
// ... create user ...
userId = 123; // Global state
});
test('add to cart', async ({ page }) => {
await login(page, userId); // Depends on previous test running first
// ... add item ...
});
If the first test fails, the second fails too—not because of a real bug, but because data is missing. The suite also can't run tests in parallel.
Fix this by giving each test its own isolated data:
test('add to cart for new user', async ({ page }) => {
// Create fresh user in beforeEach or test body
const user = await createTestUser(page, {
email: `test-${Date.now()}@example.com`,
password: 'secure-test-password'
});
await login(page, user.id);
await page.click('button:has-text("Add to Cart")');
await expect(page.locator('[data-testid="cart-count"]')).toContainText('1');
});
test('another test with its own user', async ({ page }) => {
const anotherUser = await createTestUser(page, {
email: `test-${Date.now() + 1}@example.com`,
password: 'secure-test-password'
});
// This test is independent
});
Even better, use fixtures to encapsulate setup:
type TestFixtures = {
testUser: User;
};
const test = base.extend({
testUser: async ({ page }, use) => {
const user = await createTestUser(page, {
email: `test-${Date.now()}@example.com`
});
await use(user);
// Cleanup after test (optional but recommended)
await deleteTestUser(user.id);
}
});
test('user can add to cart', async ({ page, testUser }) => {
await login(page, testUser.id);
await page.click('button:has-text("Add to Cart")');
});
</code>
<p>Each test now gets a fresh user, runs independently, and cleans up after itself. Tests can run in parallel, in any order, and won't interfere with each other.</p>
<h2 id="race-conditions-wait-for-the-right-event-before-acting">Race conditions — Wait for the right event before acting</h2>
<p>A race condition happens when your test tries to interact with an element before JavaScript has finished setting it up. The DOM element exists, but the click handler isn't attached yet:</p>
<pre><code class="language-typescript">
// ❌ Race condition
await page.goto('https://example.com/checkout');
await page.click('button:has-text("Confirm Order")'); // Handler might not be bound yet
</code>
<p>On a fast machine, the JavaScript loads in time. On CI, it doesn't. The test flakes.</p>
<p>The solution is to wait for a signal that the page is ready. This might be a network request completing, a CSS class appearing, or data attributes being set:</p>
<pre><code class="language-typescript">
// ✅ Wait for the API call that initializes the button
await page.goto('https://example.com/checkout');
// Option 1: Wait for a specific network request
await page.waitForLoadState('networkidle');
await page.click('button:has-text("Confirm Order")');
// Option 2: Wait for a data attribute that JavaScript sets
await page.locator('button:has-text("Confirm Order")').evaluate(
el => el.hasAttribute('data-ready')
);
await page.click('button:has-text("Confirm Order")');
// Option 3: Wait for a visual change (e.g., spinner disappears)
await page.locator('[data-testid="checkout-spinner"]').waitFor({ state: 'hidden' });
await page.click('button:has-text("Confirm Order")');
In your test environment, coordinate with your application team to emit or set a clear signal when the page is safe to interact with. A data-testid attribute or a specific network request is ideal.
Animations — Disable or wait for them in test environments
CSS animations and transitions create timing uncertainty. An element becomes visible, but its animation takes 300ms. If you try to click it immediately, Playwright might hit a pixel that's still animating, and the click fails.
// ❌ Element is visible but animating
await page.click('button'); // Sometimes fails mid-animation
// Animation:
// opacity: 0 -> 1 over 300ms
You have two options: disable animations in the test environment, or wait for them to complete.
Option 1: Disable animations globally
await page.addInitScript(() => {
document.documentElement.style.setProperty(
'animation-duration',
'0s',
'important'
);
document.documentElement.style.setProperty(
'transition-duration',
'0s',
'important'
);
});
Add this in a beforeEach hook or fixture. It disables all CSS animations immediately, so your tests run at full speed with zero flakiness from animation timing.
Option 2: Wait for animations explicitly
If you want to test animations, wait for them to finish:
await page.locator('button').click();
// Wait for the animation to complete
await page.locator('[data-testid="modal"]').evaluate(el => {
return el.getAnimations().every(animation => animation.playState === 'finished');
});
// Now it's safe to assert on the final state
await expect(page.locator('[data-testid="modal"]')).toBeVisible();
Or use a simpler approach: wait for the element to stabilize by checking its position doesn't change:
await page.locator('button').click();
await page.locator('[data-testid="modal"]').waitFor({ state: 'visible' });
// Add a small buffer to let animations finish
await page.waitForTimeout(300);
await expect(page.locator('[data-testid="modal"]')).toBeVisible();
For production applications, the first approach (disabling animations in tests) is cleaner and faster.
Common mistakes
- Stacking hard-coded waits with action waits. You do
await page.waitForTimeout(1000); await page.click(selector);instead of just clicking. Playwright already waits. Remove the timeout. - Waiting for parent conditions instead of child conditions. You wait for a container to be visible but don't wait for the specific button inside it to be actionable. Use precise selectors and wait for the exact element you're interacting with.
- Assuming test order. Tests that run fine locally but fail in parallel CI. Isolate data, don't rely on global state, and use fixtures. Your suite should pass running tests backward alphabetically.
- Ignoring environment differences. Your local machine has fast disk I/O. CI might have network latency. Write tests that work everywhere by waiting for actual conditions, not arbitrary durations.
Key takeaways
- Replace every
waitForTimeoutwith a web-first assertion targeting the actual condition you need. - Isolate test data: each test gets its own users, orders, and state. Use fixtures for clean setup and teardown.
- Wait for signals that the page is ready—network idle, data attributes, or spinner elements—not arbitrary time.
- Disable animations in test configuration or wait for them explicitly. Don't let CSS timing drive flakiness.
- Run tests in parallel and in random order locally. If they pass, they're not flaky.






Comments
Loading comments…