Introduction
In Part 3, you learned how to interact with the page using actions like click(), fill(), and check(). Now you need to prove those actions produced the right result.
That is where assertions come in.
An assertion is a check that confirms something is true. In Playwright, assertions are built on expect() and are designed for the web. Instead of checking once and failing immediately, they retry for a short period while the UI catches up.
This is one of the biggest reasons Playwright tests are reliable.
The expect() API at a Glance
You already saw this pattern:
await expect(page).toHaveTitle(/Playwright/);
The structure is always the same:
await expect(target).matcher(value)
targetis what you are asserting on (page, alocator, a response, etc.)matcheris the condition (toBeVisible,toHaveText,toHaveURL, …)valueis the expected value when required
For UI tests, your most common target is a locator:
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
Web-First Assertions (Auto-Retry by Default)
Playwright assertions are web-first. That means they keep re-checking until either:
- The assertion passes, or
- The assertion timeout is reached
So this:
await expect(page.getByText('Saved')).toBeVisible();
does not mean “check once right now.” It means “wait for this to become visible within the assertion timeout.”
Note: auto-retry applies to Playwright’s async web matchers (like toBeVisible and toHaveText). Generic value matchers such as expect(value).toBe(...) do not auto-retry.
By default, Playwright uses a 5 second assertion timeout unless you configure it.
You can override per assertion:
await expect(page.getByText('Saved')).toBeVisible({ timeout: 10_000 });
or globally in config:
// playwright.config.js
export default defineConfig({
expect: {
timeout: 10_000,
},
});
Core Assertions You Will Use Most
toHaveTitle()
Asserts the page title.
await expect(page).toHaveTitle('Settings');
await expect(page).toHaveTitle(/Settings/);
toHaveURL()
Asserts the current URL.
await expect(page).toHaveURL('https://example.com/dashboard');
await expect(page).toHaveURL(/\/dashboard$/);
toBeVisible()
Checks that an element is visible to the user.
await expect(page.getByRole('alert')).toBeVisible();
toHaveText()
Checks exact or regex text content.
await expect(page.getByTestId('status')).toHaveText('Saved');
await expect(page.getByTestId('status')).toHaveText(/saved/i);
toBeEnabled()
Checks that an element is enabled and can be interacted with.
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
A Realistic End-to-End Example
import { test, expect } from '@playwright/test';
test('user can save profile changes', async ({ page }) => {
await page.goto('/profile');
await page.getByLabel('Display name').fill('swacblooms');
await page.getByRole('button', { name: 'Save changes' }).click();
await expect(page).toHaveURL(/\/profile$/);
await expect(page.getByRole('status')).toHaveText('Changes saved');
await expect(page.getByRole('button', { name: 'Save changes' })).toBeEnabled();
});
Notice there are no manual sleeps. Assertions do the waiting.
Negative Assertions
You can assert that something is not true using .not:
await expect(page.getByText('Loading...')).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Delete' })).not.toBeEnabled();
Use negative assertions carefully. They are useful, but positive checks are often clearer when possible.
Soft Assertions
A regular assertion fails the test immediately. A soft assertion records the failure and allows the test to continue.
await expect.soft(page.getByTestId('first-name')).toHaveValue('Ada');
await expect.soft(page.getByTestId('last-name')).toHaveValue('Lovelace');
await expect.soft(page.getByTestId('email')).toHaveValue('ada@example.com');
// test continues even if one soft check fails
await page.getByRole('button', { name: 'Continue' }).click();
Soft assertions are useful when you want to collect multiple mismatches in one run, for example in long forms or summary pages.
Use them intentionally. For critical checks, prefer normal assertions so failures stop immediately.
Polling for Non-UI Conditions
Sometimes you need to verify something that is not a locator state, such as a value that changes over time.
Use expect.poll():
await expect
.poll(async () => {
const response = await page.request.get('https://example.com/api/jobs/123');
const data = await response.json();
return data.status;
})
.toBe('completed');
Playwright keeps polling until the value becomes 'completed' or times out.
This gives you retry behavior for computed values, API state, and other async conditions.
Common Mistakes to Avoid
- Using manual sleeps instead of assertions
// avoid
await page.waitForTimeout(2000);
// prefer
await expect(page.getByText('Saved')).toBeVisible();
- Asserting raw values too early
// fragile
const text = await page.getByTestId('status').textContent();
expect(text).toBe('Saved');
Prefer Playwright locator assertions because they auto-retry:
await expect(page.getByTestId('status')).toHaveText('Saved');
- Overusing soft assertions
If everything is soft, failures can be easy to miss. Keep core checks hard.
Under the Hood: Why Retry-ability Matters
Modern UIs are asynchronous.
- React/Vue/Angular re-render after network calls
- buttons enable after validation
- toast messages appear after state updates
If assertions checked only once, tests would be flaky.
Playwright’s retry model aligns your checks with real browser timing. Instead of adding arbitrary sleeps, you describe the desired end state and let Playwright wait for it.
That is the foundation of stable E2E tests.
What’s Next
In Part 5 you will learn playwright.config.js in depth. You will configure retries, workers, projects, baseURL, traces, screenshots, and other settings that shape how your whole suite runs locally and in CI.