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"), pressSequentially for realistic typing.
  • @axe-core/playwright — runs axe accessibility rules against the live page.
  • Network control — page.route for server responses, or MSW handlers shared with unit tests.

Keep the end-to-end suite small and focused on behaviour lower layers cannot observe.

What only the end-to-end layer can see Implicit submission by pressing Enter in a text field is tested with locator press Enter. Focus moving to the error summary is tested with toBeFocused. Summary links focusing the field under a sticky header are tested with toBeFocused and toBeInViewport. Autofill reconciliation is tested by filling fields without input events through page evaluate or a browser profile. Caret position in masked inputs is tested by reading selectionStart after typing mid-value. Cross-engine differences are tested by running the same tests in Chromium, Firefox and WebKit projects. Behaviour Playwright technique Enter submits the form locator.press("Enter") summary receives focus expect(summary).toBeFocused() link lands in field, visible toBeFocused() + toBeInViewport() autofill reconciled at submit set .value without events, then submit caret stays mid-value evaluate(el => el.selectionStart) engine differences chromium, firefox, webkit projects

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

  1. 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.
  2. 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.
  3. Assert focus after submit. The summary (or first invalid field) must be focused; toBeFocused retries until it is, so no sleeps are needed.
  4. Follow summary links and assert visibility. Focus alone is not enough if a sticky header covers the field; toBeInViewport catches that, as in scrolling invalid fields into view under sticky headers.
  5. Scan the error state with axe. Scan after errors are visible; include only the form to keep results focused and stable.
  6. 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.

One test, as a user would experience it The test presses Enter in the full name field, which submits the empty form. It waits for the error summary group, named by its heading, to receive focus. It activates the summary link for the email error. It checks that the email input is focused, is inside the viewport rather than under the sticky header, has aria-invalid true, and has an accessible description containing the error message. Press Enter in "Full name" Implicit submission. No mouse involved. Summary focused toBeFocused() on the group. Named by its heading: "There are 3 problems". Follow the email link getByRole("link", { name }) The link text is the error message. Field focused, visible, described toBeFocused, toBeInViewport, description Exactly what a keyboard or screen-reader user needs.

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.

The minimum end-to-end set for an important form The first test submits with Enter on an empty form and checks the summary is focused and its links land in visible fields. The second checks that fixing a field clears its error without resubmitting. The third mocks a 422 and checks the error lands on the right field. The fourth runs axe on the form in its error state. Keyboard submit Enter → summary focused. Links land, visible. Live correction Fix clears error. No resubmit needed. Server mapping 422 → right field. Axe in error state Scoped to the form.

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.


Related

← Testing Form Validation