How to fix flaky Playwright assertions caused by immediate `isVisible()` checks?
Playwrightlocator.isVisible() is a point-in-time query that returns the current visibility state immediately without any retrying. When used as a test assertion — especially after an action that triggers asynchronous rendering — it will return false if called before the element has had time to appear. Tests that use isVisible() to check state after async operations are structurally flaky: they pass when the render is fast enough and fail when it isn't.
Common mistake
test('shows success toast after submit', async ({ page }) => {
await page.goto('https://app.example.com/settings');
await page.getByRole('button', { name: 'Save' }).click();
// isVisible() checks right now — toast may not have rendered yet
const toastVisible = await page.getByRole('alert').isVisible();
expect(toastVisible).toBe(true); // Flaky — passes only when render is fast
});
Also flaky in conditional logic:
// Timing-dependent branch — unreliable as a test signal
if (await page.getByText('Loading').isVisible()) {
await page.waitForTimeout(2000); // Even worse
}
The fix
Replace isVisible() assertions with expect(locator).toBeVisible(), which retries automatically:
import { test, expect } from '@playwright/test';
test('shows success toast after save', async ({ page }) => {
await page.goto('https://app.example.com/settings');
await page.getByLabel('Display name').fill('Ada Lovelace');
await page.getByRole('button', { name: 'Save' }).click();
// toBeVisible retries until element appears or timeout expires
await expect(page.getByRole('alert')).toBeVisible({ timeout: 5000 });
await expect(page.getByRole('alert')).toContainText('Settings saved');
});
test('hides loading spinner after data loads', async ({ page }) => {
await page.goto('https://app.example.com/dashboard');
// toBeHidden retries until element disappears
await expect(page.getByTestId('loading-spinner')).toBeHidden({ timeout: 10000 });
await expect(page.getByTestId('stats-panel')).toBeVisible();
});
isVisible() is appropriate for non-assertion reads — for example, skipping a step when an optional element might or might not be present:
test('dismisses cookie banner if present', async ({ page }) => {
await page.goto('https://app.example.com');
// Conditional action — isVisible() is fine here because we handle both branches
const cookieBanner = page.getByRole('dialog', { name: /cookie/i });
if (await cookieBanner.isVisible()) {
await cookieBanner.getByRole('button', { name: 'Accept' }).click();
await expect(cookieBanner).toBeHidden();
}
// Continue test regardless
await page.getByRole('link', { name: 'Dashboard' }).click();
});
Why it works
expect(locator).toBeVisible() uses Playwright's internal polling mechanism — it checks the condition every ~100ms and keeps retrying until the assertion passes or the timeout is exceeded. This matches how browsers actually work: elements appear asynchronously after events, API responses, or animations. isVisible() makes a single synchronous DOM check, which is appropriate when you want a snapshot of current state but not when you need the test to wait for state to change.
Tips
- The rule of thumb: if you find yourself writing expect(await locator.isVisible()).toBe(true), replace it with await expect(locator).toBeVisible().
- isVisible() is genuinely useful in test.describe.only debugging sessions where you want to log the current page state without making the test conditional on it.
- locator.waitFor({ state: 'visible' }) is a programmatic alternative to expect(locator).toBeVisible() when you want to wait without adding an assertion to the test report.
- Flaky isVisible() checks in legacy test suites can be identified by searching for the pattern await .isVisible() followed by expect(...).toBe(true) — both should be migrated to the assertion form.