Contexts, Waiting, and Assertions
Three concepts form the core of Playwright's reliability advantage over earlier tools: browser contexts for isolation, auto-waiting to eliminate timing bugs, and web-first assertions that retry until conditions are met. Master these three and most flakiness disappears.
Browser Contexts
A browser context is a lightweight, isolated browser session — like an incognito window. Each context has its own cookies, localStorage, and cache. Unlike Selenium, where each test typically launches a new browser process, Playwright creates contexts within a single browser instance.
// Each test gets its own context automatically in @playwright/test
test('user A sees their dashboard', async ({ page }) => {
// `page` belongs to a fresh context — isolated from other tests
});
// Manual context creation for multi-user scenarios
test('two users collaborate', async ({ browser }) => {
const adminContext = await browser.newContext();
const userContext = await browser.newContext();
const adminPage = await adminContext.newPage();
const userPage = await userContext.newPage();
// Admin and user have completely separate sessions
await adminPage.goto('/admin');
await userPage.goto('/dashboard');
});
Why Contexts Matter
| Selenium Approach | Playwright Approach |
|---|---|
| One browser per test (slow startup) | One browser, many contexts (fast) |
| Shared state leaks between tests | Complete isolation per context |
| Parallel = multiple browser processes | Parallel = multiple contexts in one browser |
| Cookie cleanup between tests is manual | Contexts are disposable — no cleanup needed |
Auto-Waiting
In Selenium, the #1 source of test flakiness is timing: the test tries to interact with an element before it is ready. Playwright eliminates this by auto-waiting before every action.
When you call page.click('button'), Playwright automatically waits for the element to be:
- Attached to the DOM
- Visible (not hidden by CSS)
- Stable (not animating)
- Enabled (not disabled)
- Receiving events (not obscured by another element)
Only then does the click execute. If any condition is not met, Playwright retries until the timeout.
// Playwright: just click — auto-waiting handles timing
await page.getByRole('button', { name: 'Submit' }).click();
// Selenium: must manually wait, then click
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
WebDriverWait(driver, 10).until(
EC.element_to_be_clickable((By.XPATH, "//button[text()='Submit']"))
).click()
What Auto-Waiting Covers
| Action | Waits For |
|---|---|
click() |
Attached, visible, stable, enabled, receives events |
fill() |
Attached, visible, enabled, editable |
check() |
Attached, visible, stable, enabled |
selectOption() |
Attached, visible, enabled |
textContent() |
Attached |
isVisible() |
Nothing (returns immediately) |
When You Still Need Explicit Waits
Auto-waiting handles interactions, but sometimes you need to wait for a condition before proceeding:
// Wait for a specific response after triggering an action
await page.waitForResponse('**/api/orders');
// Wait for navigation
await page.waitForURL('**/dashboard');
// Wait for an element to disappear (loading spinner)
await page.getByTestId('spinner').waitFor({ state: 'hidden' });
Web-First Assertions
Playwright's expect() assertions are "web-first" — they retry until the condition is met or the timeout expires. This is fundamentally different from instant assertions that check once and fail.
// Web-first: retries until text appears (up to 5s by default)
await expect(page.getByRole('alert')).toHaveText('Saved successfully');
// Instant assertion: checks once, fails immediately if not ready
// DON'T DO THIS:
const text = await page.getByRole('alert').textContent();
expect(text).toBe('Saved successfully'); // fragile!
Common Web-First Assertions
// Element state
await expect(page.getByRole('button')).toBeVisible();
await expect(page.getByRole('button')).toBeEnabled();
await expect(page.getByTestId('input')).toHaveValue('hello');
await expect(page.getByRole('alert')).toHaveText('Success');
// Page state
await expect(page).toHaveTitle(/Dashboard/);
await expect(page).toHaveURL(/\/dashboard/);
// Element count
await expect(page.getByRole('listitem')).toHaveCount(5);
// CSS and attributes
await expect(page.getByTestId('status')).toHaveClass(/active/);
await expect(page.getByRole('link')).toHaveAttribute('href', '/about');
Assertion Timeout
// Default timeout: 5 seconds. Override per assertion:
await expect(page.getByText('Report ready')).toBeVisible({ timeout: 30_000 });
// Or set globally in config:
// expect: { timeout: 10_000 }
Putting It All Together
test('add item to cart', async ({ page }) => {
await page.goto('/products');
// Auto-wait: clicks when button is ready
await page.getByRole('button', { name: 'Add to Cart' }).first().click();
// Web-first assertion: retries until cart badge shows "1"
await expect(page.getByTestId('cart-count')).toHaveText('1');
// Navigate and verify
await page.getByRole('link', { name: 'Cart' }).click();
await expect(page).toHaveURL(/.*cart/);
await expect(page.getByRole('listitem')).toHaveCount(1);
});
No explicit waits. No sleep statements. No flaky timing issues. The auto-waiting and web-first assertions handle all synchronization.
Key Takeaways
- Browser contexts provide lightweight, isolated sessions — no state leaks between tests
- Auto-waiting checks actionability (visible, stable, enabled) before every interaction
- Web-first assertions retry until the condition is met, eliminating race conditions
- Use
waitFor()andwaitForResponse()only when you need to wait for async operations beyond simple interactions - Never use instant assertions on dynamic content — always use
expect()from@playwright/test