Testing Form Error Messages with Playwright
This recipe asserts that a specific field’s inline error message appears when the field is invalid, clears when it is corrected, is programmatically tied to the input through aria-describedby, and is mirrored in an error summary — all in a real browser with auto-retrying assertions.
When to Use This Recipe
Reach for this when you need to prove the content and wiring of error messaging, not just that submission was blocked. It is the right tool when your form follows the house pattern — <form novalidate> rendering its own messages — and you must verify that the error text screen readers announce is the same text sighted users see, that the association survives recovery, and that an error summary stays in sync. If you only need to confirm submission gating and focus routing, the parent guide on Playwright form validation testing covers that flow; this page drills into the message itself.
Minimal Working Test
Assume a signup form where leaving the email empty renders an inline error with id email-error, sets aria-invalid="true", and adds aria-describedby="email-hint email-error". The test asserts the full association.
import { test, expect, type Page } from '@playwright/test';
// Helper: resolve the message id referenced by aria-describedby and
// return the live element so we can assert its text and role.
async function describedByError(page: Page, fieldLabel: string) {
const field = page.getByLabel(fieldLabel);
const describedBy = await field.getAttribute('aria-describedby');
// aria-describedby is a space-separated id list; the error id ends in "-error".
const errorId = (describedBy ?? '')
.split(/\s+/)
.find((id) => id.endsWith('-error'));
return { field, error: errorId ? page.locator(`#${errorId}`) : null, errorId };
}
test('email error appears, is associated, and clears', async ({ page }) => {
await page.goto('/signup');
// 1. Trigger validation with an empty field.
await page.getByRole('button', { name: 'Create Account' }).click();
const { field, error, errorId } = await describedByError(page, 'Email Address');
// 2. The field is marked invalid for assistive technology.
await expect(field).toHaveAttribute('aria-invalid', 'true');
// 3. aria-describedby actually points at a rendered error element.
expect(errorId).toBeTruthy();
await expect(error!).toBeVisible();
await expect(error!).toHaveText(/enter your email/i);
// 4. The error is exposed as an alert, not just styled text.
await expect(error!).toHaveRole('alert');
// 5. Correcting the value clears the message and the ARIA state.
await field.fill('dev@example.com');
await field.blur();
await expect(field).toHaveAttribute('aria-invalid', 'false');
await expect(page.locator(`#${errorId}`)).toHaveCount(0);
});
Resolving the message through aria-describedby rather than a hardcoded selector means the test proves the association a screen reader relies on — if the wiring breaks, errorId is undefined and the assertion fails. This mirrors the renderer contract from how to use setCustomValidity correctly and the broader accessible-error approach in inline error messaging strategies.
Asserting the Error Summary
A form-level summary lets keyboard users jump to the first error. Assert it lists the failing field and links back to it.
test('error summary lists the failing field and links to it', async ({ page }) => {
await page.goto('/signup');
await page.getByRole('button', { name: 'Create Account' }).click();
// The summary is a labelled region announced on submit.
const summary = page.getByRole('alert').filter({ hasText: /problem|fix the following/i });
await expect(summary).toBeVisible();
// It contains a link whose target is the invalid input's id.
const link = summary.getByRole('link', { name: /email/i });
await expect(link).toHaveAttribute('href', '#email');
// Activating the link moves focus to the field, per the focus contract.
await link.click();
await expect(page.getByLabel('Email Address')).toBeFocused();
});
The toBeFocused() assertion after clicking the summary link verifies the same focus-routing behavior documented in managing focus after validation failure.
Option Reference
| Assertion | What it proves | Notes |
|---|---|---|
toHaveAttribute('aria-invalid', 'true') |
Field is flagged invalid | Auto-retries; assert 'false' after recovery |
getAttribute('aria-describedby') |
Field references its message | Returns a space-separated id list; parse the -error id |
toHaveText(/regex/) |
Message copy is correct | Use a regex for case/whitespace tolerance |
toHaveRole('alert') |
Message is announced | Confirms it is in the accessibility tree, not just visible |
toHaveCount(0) |
Message was removed | The clear-on-recovery invariant |
toBeFocused() |
Summary link routes focus | Proves the keyboard escape hatch works |
Verification Steps
- Run with
--headed --project=chromiumand watch the error render, then disappear when you correct the field. - Open devtools, inspect the email input, and confirm
aria-describedbylists both the hint and the error id while invalid, and drops the error id on recovery. - With a screen reader active, submit the empty form and confirm the alert text matches the
toHaveTextassertion verbatim. - Run the suite across
firefoxandwebkitprojects to catch live-region timing differences.
Edge Cases & Failure Modes
The error id is reused but not cleared
If the renderer leaves a stale email-error node in the DOM after recovery, toHaveCount(0) fails. Fix the renderer to remove the node (or its text and role) when the field becomes valid, then the assertion passes. This is the DOM mirror of the setCustomValidity('') reset.
aria-describedby overwrites the hint
A renderer that sets aria-describedby="email-error" and drops the existing email-hint breaks the description chain. Assert both ids are present while invalid:
const describedBy = await page.getByLabel('Email Address').getAttribute('aria-describedby');
expect(describedBy).toContain('email-hint');
expect(describedBy).toContain('email-error');
Summary renders before focus is set
If toBeFocused() flakes after a summary-link click, the handler is moving focus before the target is in the DOM or is tabindex-eligible. The web-first assertion already retries; if it still fails, the field is genuinely not receiving focus — verify the link target id matches the input id exactly.
Frequently Asked Questions
Why assert through aria-describedby instead of a CSS selector?
Because the association is the accessibility contract. Resolving the error element from the field's
aria-describedby proves a screen reader can reach the same message. A CSS selector would
pass even if the wiring that assistive technology depends on were broken.
How do I assert the error disappears after the user fixes the field?
Fill a valid value, blur the field, then assert aria-invalid is "false"
and the error node has toHaveCount(0). This catches a renderer that forgets to remove the
message — the DOM equivalent of failing to call setCustomValidity('').
Should I assert the exact error text?
For your own custom messages, yes — assert them with a tolerant regex so copy tweaks and whitespace do not break the test. For native browser messages, assert non-empty instead, since the wording is engine- and locale-specific.
Related Guides
- Playwright Form Validation Testing — the full submission, focus, and async test flow this recipe extends
- Inline Error Messaging Strategies — how the messages under test should be designed and placed
- Managing Focus After Validation Failure — the focus routing the summary link asserts
- WCAG 2.2 Form Compliance Checklists — the success criteria these assertions cover