Sometimes you need to verify that something is NOT there. Error message is NOT visible. Button is NOT disabled. Checkbox is NOT checked. Playwright uses the .not modifier for this. But there is a subtle trap with visibility that trips people up.
import { test, expect } from '@playwright/test';
test('negative assertions', async ({ page }) => {
await page.goto('/banking/dashboard');
// NOT visible
await expect(page.getByRole('alert')).not.toBeVisible();
// NOT disabled (= enabled)
await expect(page.getByRole('button', { name: 'Transfer' })).not.toBeDisabled();
// NOT checked
await expect(page.getByRole('checkbox', { name: 'Auto-pay' })).not.toBeChecked();
// Does NOT have specific text
await expect(page.getByTestId('status')).not.toHaveText('Rejected');
// Does NOT contain substring
await expect(page.getByTestId('message')).not.toContainText('error');
// Does NOT have specific attribute value
await expect(page.getByRole('button', { name: 'Save' }))
.not.toHaveAttribute('disabled', '');
// Element does NOT exist in DOM
await expect(page.getByTestId('error-modal')).not.toBeAttached();
});These two look like they do the same thing. They mostly do. But there is a difference in semantics that matters in edge cases.
| Scenario | not.toBeVisible() | toBeHidden() |
|---|---|---|
| Element has display:none | Passes | Passes |
| Element has visibility:hidden | Passes | Passes |
| Element not in DOM at all | Passes | Passes |
| Element has zero size (0x0px) | Passes | Passes |
| Element is visible | Fails | Fails |
| Semantic meaning | Not currently visible | Intentionally hidden |
In practice, the results are the same. The difference is intent. Use not.toBeVisible() when you want to say "this element should not be showing right now." Use toBeHidden() when you want to say "this element is supposed to be hidden by design." Pick one convention and stick with it in your team.
import { test, expect } from '@playwright/test';
test('after successful login, error message disappears', async ({ page }) => {
await page.goto('/banking');
// First attempt: wrong password
await page.getByLabel('Username').fill('testuser');
await page.getByLabel('Password').fill('wrong');
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByRole('alert')).toBeVisible(); // Error shows
// Second attempt: correct password
await page.getByLabel('Password').fill('correct123');
await page.getByRole('button', { name: 'Login' }).click();
// Error should disappear
await expect(page.getByRole('alert')).not.toBeVisible();
// Login form should be gone
await expect(page.getByLabel('Password')).not.toBeVisible();
});
test('deleted item no longer appears in list', async ({ page }) => {
await page.goto('/shopping/cart');
const item = page.getByRole('row').filter({ hasText: 'Laptop' });
await item.getByRole('button', { name: 'Remove' }).click();
// Item should be gone
await expect(page.getByRole('row').filter({ hasText: 'Laptop' }))
.not.toBeVisible();
});Negative assertions also auto-retry. If you assert not.toBeVisible() and the element is currently visible, Playwright waits up to 5 seconds for it to disappear. If it is still visible after 5 seconds, the assertion fails. This is the correct behavior for elements that should vanish after an action.
Use negative assertions after destructive actions -- delete, logout, dismiss. They verify the action actually took effect. A "Delete" button that does not actually delete is caught by not.toBeVisible() on the deleted item.
Q: What is the difference between not.toBeVisible() and toBeHidden() in Playwright?
A: Functionally, they produce the same result -- both pass when the element is not visible and both fail when it is visible. The difference is semantic. not.toBeVisible() reads as "I expect this element to not be visible right now" -- useful for elements that disappear after an action, like error messages. toBeHidden() reads as "I expect this element to be hidden" -- useful for elements that are hidden by design, like collapsed accordions. Most teams pick one and standardize. I prefer not.toBeVisible() because it reads more naturally in English.
Key Point: Use .not to invert any assertion. not.toBeVisible() and toBeHidden() produce the same result -- pick one convention for your team.