Component tests can prove an error message is rendered and wired; only a real browser proves that pressing Enter on the last field submits the form, that focus actually lands in the error summary, that following a summary link scrolls to and focuses the right input under a sticky header, and that autofill and masked inputs behave.
Playwright drives Chromium, Firefox and WebKit with the same API, and its role- and label-based locators double as an accessibility check: if a test cannot find a field by its label, neither can a screen-reader user. This page, part of testing form validation, writes a compact set of end-to-end tests for form errors, including an axe scan in the error state.
Context and prerequisites
Tools used:
- Locators —
page.getByLabel("Email address"),page.getByRole("button", { name: "Create account" }),page.getByRole("link", { name: /enter your email/i }). They auto-wait and retry. - Web-first assertions —
await expect(locator).toBeFocused(),toHaveAttribute,toHaveAccessibleDescription,toBeInViewport. They retry until the condition holds or times out, removing arbitrary sleeps. - Keyboard —
page.keyboard.press("Tab"),locator.press("Enter"),pressSequentiallyfor realistic typing. @axe-core/playwright— runs axe accessibility rules against the live page.- Network control —
page.routefor server responses, or MSW handlers shared with unit tests.
Keep the end-to-end suite small and focused on behaviour lower layers cannot observe.
The core pattern: a small, focused error suite
// tests/signup-errors.spec.ts
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test.describe("signup form errors", () => {
test.beforeEach(async ({ page }) => { await page.goto("/signup"); });
test("Enter on an empty form shows a focused summary with working links", async ({ page }) => {
await page.getByLabel("Full name").press("Enter"); // implicit submission
const summary = page.getByRole("group", { name: /there are \d+ problems/i });
await expect(summary).toBeFocused();
const link = summary.getByRole("link", { name: "Enter your email address" });
await link.click();
const email = page.getByLabel("Email address");
await expect(email).toBeFocused();
await expect(email).toBeInViewport(); // not hidden under the sticky header
await expect(email).toHaveAttribute("aria-invalid", "true");
await expect(email).toHaveAccessibleDescription(/enter your email address/i);
});
test("fixing a field clears its error without another submit", async ({ page }) => {
await page.getByRole("button", { name: "Create account" }).click();
const email = page.getByLabel("Email address");
await email.pressSequentially("[email protected]");
await expect(email).not.toHaveAttribute("aria-invalid", "true");
await expect(page.getByText("Enter your email address")).toHaveCount(0);
});
test("the error state has no axe violations", async ({ page }) => {
await page.getByRole("button", { name: "Create account" }).click();
await expect(page.getByRole("group", { name: /problems/ })).toBeVisible();
const results = await new AxeBuilder({ page }).include("form").analyze();
expect(results.violations).toEqual([]);
});
test("server 422 lands on the right field", async ({ page }) => {
await page.route("**/api/signup", (route) => route.fulfill({
status: 422, contentType: "application/problem+json",
body: JSON.stringify({ type: "https://api.example.com/problems/validation",
errors: [{ pointer: "#/email", detail: "That email is used by another account." }] }),
}));
await page.getByLabel("Full name").fill("Ada Lovelace");
await page.getByLabel("Email address").fill("[email protected]");
await page.getByLabel("Create a password").fill("correct horse battery");
await page.getByRole("button", { name: "Create account" }).click();
await expect(page.getByLabel("Email address")).toHaveAccessibleDescription(/used by another account/);
});
});
Step-by-step walkthrough
- Locate by role and label only. If
getByLabel("Email address")fails, the label is missing or broken — a real accessibility bug surfaced by the test. - Submit the way users do. Press Enter in a field and click the button in separate tests; implicit submission has its own rules, covered in implicit submission and the Enter key.
- Assert focus after submit. The summary (or first invalid field) must be focused;
toBeFocusedretries until it is, so no sleeps are needed. - Follow summary links and assert visibility. Focus alone is not enough if a sticky header covers the field;
toBeInViewportcatches that, as in scrolling invalid fields into view under sticky headers. - Scan the error state with axe. Scan after errors are visible; include only the form to keep results focused and stable.
- Control the server response.
page.route(or shared MSW handlers) produces 422, 409 and 5xx responses so server-error mapping is tested end to end.
Why the error state needs its own accessibility scan
Accessibility scans are commonly run once, on page load, where forms look fine: every input has a label, contrast is good, nothing is invalid. The problems appear when errors do — an error container with an id referenced before it exists, an error summary heading that skips levels, red text on a pink background that fails contrast, an aria-describedby pointing at a removed element, duplicate ids when the same error component renders twice. None of these exist in the initial state, so none are caught unless the scan runs after a failed submit. Adding one scan in the error state to each important form catches a class of regressions that otherwise reach users.
Keeping selectors meaningful as the UI evolves
Role- and label-based locators have a second benefit beyond accessibility: they survive visual redesigns. A test that finds “the button named Create account” keeps working when the button moves, changes colour or gains an icon, and fails only when its accessible name changes — which is a change users of assistive technology would notice too. Where a form has genuinely ambiguous elements, such as several “Remove” buttons in a repeatable group, prefer giving each a more specific accessible name (“Remove contact 2”) over adding test ids, because the more specific name also helps screen-reader users. Reserve data-testid for structure with no meaningful accessible name at all.
Failure modes and edge cases
1. Asserting on text that is not associated
getByText("Enter your email address") finds the message even if aria-describedby is broken. Always pair it with toHaveAccessibleDescription on the input.
2. Flaky focus assertions
Focus moves after a render or an animation. Web-first assertions retry; avoid evaluate(() => document.activeElement) snapshots taken once, which race with the update.
3. Autofill is hard to simulate
Real browser autofill cannot be triggered from tests. Simulate its effect — set input.value in page.evaluate without dispatching events — and assert that submit reconciles it, per handling browser autofill in controlled inputs.
4. Viewport-dependent behaviour
Sticky headers and scrolling differ between desktop and mobile viewports. Run the focus-and-visibility test in at least one mobile project (devices["iPhone 13"]) as well as desktop.
5. Axe results that vary
Scanning the whole page includes third-party widgets and changes with unrelated content. Scope scans with .include("form") and disable rules only with a comment explaining why.
Verification checklist
Frequently Asked Questions
Can Playwright test screen-reader output?
Not directly — it cannot run NVDA or VoiceOver. It can assert the accessibility tree through accessible names, descriptions and roles, and snapshot it with toMatchAriaSnapshot. Real screen-reader checks remain a manual or specialised-tool task, described in the screen-reader testing matrix for form errors.
Should end-to-end tests hit a real backend?
Some should — a smoke test of the full flow against a staging environment catches contract drift. Error-scenario tests are more reliable with mocked responses, because producing a 409 or a 429 on demand from a real backend is awkward and slow.
How do I keep the suite fast?
Keep it small, run tests in parallel, reuse authentication state, and push anything that does not need a real browser down to component tests. A handful of well-chosen tests per form covers the behaviours only browsers reveal.