Development How-to

An axe check passes your agent's React form. A role query doesn't. Run both

An agent-written signup form with a div for a button and no label sailed through axe in Vitest. Adding two role-based assertions caught what axe didn't. Setup takes about fifteen minutes.

A stack of four translucent glass layers, the top a blank form, the bottom a sparse tree of linked nodes, joined by one glowing thread

An agent builds a signup form in a minute. It has an email field with a placeholder, a “Sign up” call to action, a logo and a red error message. It looks right, the unit tests pass, and a keyboard user can’t submit it, because the “button” is a div with an onClick.

The obvious fix is an axe check in the test suite. It’s worth having, and it will catch the missing alt on the logo straight away. It will not catch the div button or the field that has only a placeholder. Both of those slip through, as the output below shows, and they’re exactly the shortcuts generated markup takes.

So the gate has two halves: vitest-axe for the rules axe can decide, and a few Testing Library role queries for what it can’t. The technique needs a test runner and a component. It works with any coding agent that can run npx vitest run, and the prompt at the end doesn’t depend on one.

The component that looks fine

Here’s the form, as an agent might first write it. Save it as src/SignupForm.tsx:

import { useState } from "react";

export function SignupForm({ onSubmit }: { onSubmit: (email: string) => void }) {
  const [email, setEmail] = useState("");
  const [error, setError] = useState("");
  return (
    <form onSubmit={(e) => { e.preventDefault(); if (!email.includes("@")) { setError("Enter a valid email"); return; } onSubmit(email); }}>
      <input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
      {error && <span style={{ color: "red" }}>{error}</span>}
      <div onClick={() => onSubmit(email)}>Sign up</div>
      <img src="/logo.png" />
    </form>
  );
}

Nothing here is exotic. Every line is a shortcut a model takes because the visual result is identical. The div also skips validation, so clicking it submits whatever is in the box.

Set up the gate

Install the pieces:

npm i -D vitest jsdom axe-core vitest-axe react react-dom @testing-library/react @testing-library/dom @types/react @types/react-dom typescript

vitest-axe is a fork of jest-axe. Its README describes registering the matchers by importing vitest-axe/extend-expect in a setup file. With Vitest 5.1.0 and vitest-axe 0.1.0 that import registered nothing:

Error: Invalid Chai property: toHaveNoViolations

Registering the matchers by hand works, and it’s the other route the README documents. The setup file also needs cleanup after each test. Vitest’s globals are off by default, and Testing Library only cleans up on its own when globals are on. Without it, every test’s DOM piles up in the same document and role queries start finding several buttons.

// vitest.setup.ts
import { afterEach, expect } from "vitest";
import { cleanup } from "@testing-library/react";
import * as matchers from "vitest-axe/matchers";

expect.extend(matchers);
afterEach(cleanup);
// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: { environment: "jsdom", setupFiles: ["./vitest.setup.ts"] },
});

Use jsdom here. The vitest-axe README says the library won’t work under happy-dom, because of a bug in Node.prototype.isConnected.

For tsc to know about the matcher, add a declaration file, vitest.d.ts:

import "vitest";
import type { AxeMatchers } from "vitest-axe/matchers";

declare module "vitest" {
  interface Assertion<T = any> extends AxeMatchers {}
  interface AsymmetricMatchersContaining extends AxeMatchers {}
}

First test: what axe sees

// src/SignupForm.a11y.test.tsx
import { render } from "@testing-library/react";
import { axe } from "vitest-axe";
import { expect, it } from "vitest";
import { SignupForm } from "./SignupForm";

it("has no axe violations", async () => {
  const { container } = render(<SignupForm onSubmit={() => {}} />);
  expect(await axe(container)).toHaveNoViolations();
});

Run npx vitest run and the output names the one thing axe found:

Expected the HTML found at $('img') to have no violations:

<img src="/logo.png">

Received:

"Images must have alternative text (image-alt)"

Fix any of the following:
  Element does not have an alt attribute
  aria-label attribute does not exist or is empty
  ...
You can find more information on this issue here: 
https://dequeuniversity.com/rules/axe/4.14/image-alt?application=axeAPI

The message is good enough to hand straight back to an agent. Set alt="" on the logo (it’s decorative) and run again. The test goes green.

The form is still broken, and the green tick says otherwise. axe reported nothing about the div that acts as a button, and nothing about an email input whose only name is a placeholder. The README for axe-core says the engine finds “on average 57% of WCAG issues automatically”, and that elements it can’t judge with certainty come back as incomplete. That figure is a reminder to treat a passing axe run as a floor.

Second test: ask the accessibility tree

Testing Library’s getByRole queries what the browser exposes to assistive technology. Its docs call it the top preference for just about everything, which makes it a natural accessibility check: if a role query can’t find the control, a screen reader can’t either. Add this to the same file:

it("exposes a named submit button and a labelled email field", () => {
  const { getByRole } = render(<SignupForm onSubmit={() => {}} />);
  expect(getByRole("button", { name: "Sign up" })).toBeTruthy();
  expect(getByRole("textbox", { name: "Email address" })).toBeTruthy();
});

Against the agent’s markup it fails, and the failure prints the accessibility tree it did find:

TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "Sign up"

Here are the accessible roles:

  textbox:

  Name "":
  <input
    placeholder="Email"
    type="email"
    value=""
  />

  presentation:

  Name "":
  <img
    alt=""
    src="/logo.png"
  />

Two facts in four lines. The div isn’t in the tree as a button at all, and the textbox has an empty name. The placeholder doesn’t count as one here, and the axe run didn’t object.

The fix, and a test for the error message

Rewrite the component with the semantics the tests ask for:

import { useId, useState } from "react";

export function SignupForm({ onSubmit }: { onSubmit: (email: string) => void }) {
  const [email, setEmail] = useState("");
  const [error, setError] = useState("");
  const emailId = useId();
  const errorId = `${emailId}-error`;
  return (
    <form
      noValidate
      onSubmit={(e) => {
        e.preventDefault();
        if (!email.includes("@")) { setError("Enter a valid email"); return; }
        setError("");
        onSubmit(email);
      }}
    >
      <label htmlFor={emailId}>Email address</label>
      <input
        id={emailId}
        type="email"
        value={email}
        aria-invalid={error ? true : undefined}
        aria-describedby={error ? errorId : undefined}
        onChange={(e) => setEmail(e.target.value)}
      />
      {error && <span id={errorId} role="alert">{error}</span>}
      <button type="submit">Sign up</button>
      <img src="/logo.png" alt="" />
    </form>
  );
}

The label comes from useId, so two forms on a page never share an id. The error gets role="alert" so it’s announced when it appears, and the input points at it with aria-describedby. The noValidate attribute is deliberate: without it the browser (and jsdom) blocks submission of a type="email" field with its own message, and the component’s own error never renders.

That last behaviour is worth a test, because it’s a state axe never sees. The static render has no error in it. Add a third test that triggers the error and then runs axe on that state:

import { fireEvent, render } from "@testing-library/react";

it("announces the error and stays free of violations while it shows", async () => {
  const { container, getByRole } = render(<SignupForm onSubmit={() => {}} />);
  fireEvent.change(getByRole("textbox", { name: "Email address" }), { target: { value: "nope" } });
  fireEvent.click(getByRole("button", { name: "Sign up" }));
  expect(getByRole("alert").textContent).toBe("Enter a valid email");
  expect(getByRole("textbox", { name: "Email address" }).getAttribute("aria-invalid")).toBe("true");
  expect(await axe(container)).toHaveNoViolations();
});

(Replace the first import line with the one above so fireEvent is in scope.) Now the whole file passes, and npx tsc is clean:

 Test Files  1 passed (1)
      Tests  3 passed (3)

What jsdom can’t tell you

Colour contrast is the most common axe finding in real projects, and it doesn’t run in jsdom. The axe-core README says its jsdom support is limited and that color-contrast is known not to work there. Rather than a failure, a low-contrast paragraph (#ccc on white) comes back like this when the violation and incomplete lists are printed:

[] [ 'color-contrast' ]

No violations, and color-contrast sitting in incomplete. Deque’s docs call that bucket needs review, meaning the rule couldn’t decide. toHaveNoViolations ignores it, so the test stays green. Two options: assert that incomplete is empty for rules you care about, which will fail on contrast in jsdom, or disable the rule with rules: { "color-contrast": { enabled: false } } in the axe options and check contrast in a real browser, through Playwright with @axe-core/playwright, for example.

The same limit applies to anything layout-dependent. axe only checks rendered content, and its docs say it doesn’t test hidden regions such as closed menus or modals until you make them visible. Render the dialog open, then run it.

A prompt that makes the agent use the gate

Add or change the component, then run `npx vitest run`.
Every component gets two tests: one axe run with toHaveNoViolations,
and role queries for each interactive control (getByRole with a name).
Use native elements (button, a, label, input), never a div with onClick.
If a role query fails, fix the markup, not the query.
Test each visible state: default, error, open.

The last line of the prompt matters most. Agents “fix” a failing getByRole by switching to getByText, which finds the div and defeats the check.

When not to bother

If the project already runs Playwright with axe against every page, and the pages are mostly static, the Vitest layer adds little. It earns its place on components with states that only appear after interaction (errors, dialogs, expanded rows), where a page-level scan never looks.

Treat the setup as a smoke alarm, not an audit. Neither axe nor a role query tests keyboard order, focus management or what a screen reader actually says, and no test here replaces tabbing through the form once.

First fifteen minutes

Pick one form in your project that an agent wrote. Install the packages above, add the setup file, and write the two-test file against it. Run it before changing a line of the component, and read what the role query prints.