Web-first assertions: Playwright lessons from production
When you're running tests against real applications, timing is everything. A button might take 200ms to appear, a dropdown might populate after an API call, or a success message might flash briefly. We've learned across multiple production deployments that the difference between flaky and reliable tests often comes down to how you structure assertions. Web-first assertions in Playwright aren't just a convenience—they're a fundamental shift in how we think about synchronization, and they're central to writing tests that work in production.
Why assertions matter more than you think
Before diving into the mechanics, let's be clear about why this matters. In a CRM system processing customer data, we need to confirm a record was saved. In an e-commerce platform, we need to verify that inventory updated correctly. In a ride-hailing app, we need to ensure that a booking status changed. All of these require assertions that are resilient to the variable timing of real-world systems.
Most developers coming from Selenium or older test frameworks are used to explicit waits—manually polling for conditions, writing helper functions, managing timeouts. Playwright's approach is different. It treats the entire browser as an observable system and builds waiting into its core primitives, including assertions.
Use expect with locators—toBeVisible, toHaveText, toHaveValue
The fundamental pattern we use in production is pairing Playwright's expect with locators. This looks deceptively simple:
await expect(page.getByRole('status')).toHaveText('Saved');What's happening under the hood is powerful. Playwright doesn't just check the status element once. It retries the assertion repeatedly (by default, for up to 5 seconds) until one of two things happens: either the text matches and the assertion passes, or the timeout is reached and it fails. This built-in retry mechanism eliminates an entire class of flaky tests.
Let's walk through a concrete example from an order management system:
// User submits a form
await page.getByRole('button', { name: 'Submit Order' }).click();
// We want to confirm the success state appears
// This will retry up to 5 seconds, checking multiple times per second
await expect(page.getByRole('status')).toHaveText('Order placed successfully');
// Similarly, we can check visibility
await expect(page.getByRole('button', { name: 'Download Invoice' })).toBeVisible();
// Or verify form values
await expect(page.getByLabel('Customer Name')).toHaveValue('John Doe');Each of these assertions retries automatically. If the success message hasn't rendered yet because the backend is still processing, Playwright keeps checking. If the download button is hidden until the order status transitions, Playwright waits. No manual polling, no setTimeout loops, no flaky sleeps.
The key locator-based assertions we rely on in production include:
- toBeVisible() — Checks that an element is in the DOM and not hidden by CSS or display:none
- toHaveText() — Confirms exact or partial text content, with retry built in
- toHaveValue() — Validates input field or select values after async updates
- toBeEnabled() — Ensures buttons or controls are interactive after state changes
- toHaveAttribute() — Checks data attributes or aria attributes that reflect application state
- toHaveClass() — Verifies CSS classes that indicate state (useful when testing state transitions)
The pattern is always the same: await expect(locator).assertion(). The locator is observed, the assertion retries, and your test remains stable.
They retry—no manual polling needed
Let's contrast what we used to do versus what Playwright enables. In a legacy Selenium-based test for a trading platform, confirming a position update might look like:
// Old approach with manual polling (we avoid this now)
let positionUpdated = false;
for (let i = 0; i < 10; i++) {
const text = await page.locator('.position-value').textContent();
if (text.includes('$50,000')) {
positionUpdated = true;
break;
}
await page.waitForTimeout(500);
}
expect(positionUpdated).toBe(true);With Playwright's retry-built-in assertions, we write:
// Modern approach: let Playwright handle the retry
await expect(page.locator('.position-value')).toHaveText('$50,000');The second version is cleaner, safer, and faster. Playwright doesn't just wait a fixed amount—it checks frequently and passes as soon as the condition is true. There's no wasted time waiting for a full 500ms sleep if the text appears after 100ms.
We also don't need to worry about race conditions in our test code. The assertion is atomic: Playwright ensures the locator resolves, the element is stable, and the condition is verified before returning. In a SaaS platform processing real user data, this atomic behavior prevents the "test passes locally but fails in CI" scenarios that used to haunt us.
Soft assertions—expect.soft collects failures without stopping
Sometimes in a single test flow—say, verifying a user profile after an update—we want to check multiple things without stopping at the first failure. This is where soft assertions shine.
// Hard assertion: stops at first failure
await expect(page.getByLabel('Email')).toHaveValue('user@example.com');
// If this fails, test stops here
await expect(page.getByLabel('Phone')).toHaveValue('+1234567890');With soft assertions, we collect multiple failures:
// Soft assertions: collect failures, don't stop immediately
await expect.soft(page.getByLabel('Email')).toHaveValue('user@example.com');
await expect.soft(page.getByLabel('Phone')).toHaveValue('+1234567890');
await expect.soft(page.getByLabel('Country')).toHaveValue('USA');
// Test continues and all three checks run
// Playwright reports all failures at the endIn production, we use soft assertions when testing a screen with many fields or a dashboard with multiple status indicators. A user profile page in a CRM might have 10 fields to verify. Soft assertions let us see which ones failed in a single test run, rather than fixing one, re-running, and discovering the next failure.
The tradeoff: soft assertions make the test report noisier if many things are wrong. We reserve them for comprehensive verification steps, not for every single check.
Avoid manual checks—isVisible() returns once and does not retry
This is where teams often stumble. Playwright provides lower-level methods that do not retry:
// Danger: no retry
const isVisible = await page.locator('.notification').isVisible();
if (!isVisible) {
// If the notification hasn't rendered yet, this fails immediately
throw new Error('Notification not visible');
}
// Similar non-retrying methods
const text = await page.locator('.status').textContent();
const value = await page.locator('input').inputValue();
const count = await page.locator('.item').count();These methods return once. They're useful for conditional logic in your test (branching based on whether an element exists), but they're not suitable for verifying expected states. We've seen many flaky tests that mixed these lower-level methods with timing assumptions.
The right approach in production is: use expect() for verification, use isVisible()/textContent()/etc. for conditional branching only.
// Correct: use expect for state verification
await expect(page.locator('.notification')).toBeVisible();
// Correct: use isVisible() for branching logic only
if (await page.locator('.legacy-warning').isVisible()) {
await page.getByRole('button', { name: 'Acknowledge' }).click();
}In a real scenario from an e-commerce platform, we might need to handle an optional promotional banner:
// Check if promo banner is shown, and dismiss it if present
if (await page.locator('[data-testid="promo-banner"]').isVisible()) {
await page.locator('[data-testid="promo-banner"] button:has-text("Close")').click();
}
// Now proceed with the main flow, confident the banner is dismissed
await expect(page.getByRole('heading', { name: 'Checkout' })).toBeVisible();The key: isVisible() and similar methods tell you "what is true right now," while expect() tells you "wait until this becomes true."
Common mistakes
Mixing expect() with isVisible(): Don't write if (await expect(...).toBeVisible()). Expect returns undefined or throws; it's not for conditionals. Use if (await locator.isVisible()) for branching and await expect(locator).toBeVisible() for verification.
Ignoring the timeout: The default 5-second retry timeout is sensible for most cases, but slow API responses in CI environments sometimes need adjustment. Set it explicitly for slow operations:
await expect(page.locator('.report-data')).toHaveText('Complete', { timeout: 15000 });Asserting on volatile text: Timestamps, random IDs, and counters change. Use partial matching or regex:
// Bad: exact text that includes a timestamp
await expect(page.locator('.receipt')).toHaveText('Order placed at 14:32:45 UTC');
// Good: partial text or regex
await expect(page.locator('.receipt')).toContainText('Order placed at');
await expect(page.locator('.order-id')).toHaveText(/^ORD-\d{6}$/);Over-using soft assertions: If your test has 20 soft assertions and 5 fail, the test output is hard to read. Reserve soft assertions for scenarios where partial verification makes sense—a dashboard with multiple widgets, a form with many fields. Don't soft-assert everything.
Key takeaways
- Use
await expect(locator).assertion()for all state verification. Assertions retry automatically up to the timeout. - Locator-based assertions like toBeVisible(), toHaveText(), and toHaveValue() are your primary tools; they're atomic and safe.
- Manual polling, explicit waits, and setTimeout loops are anti-patterns in Playwright. If you're writing a loop to check a condition, use expect() instead.
- Soft assertions (expect.soft) let you verify multiple conditions without stopping at the first failure—useful for comprehensive checks, but avoid overusing them.
- Lower-level methods like isVisible(), textContent(), and inputValue() do not retry. Use them only for conditional branching, never for verification.
- Assertions are part of Playwright's contract with your application: they express intent, they're self-documenting, and they make flaky tests visible immediately so you can fix the real timing issue.






Comments
Loading comments…