BXUP Track progress & test yourself — free
Frontend & Mobile · Playbook · Foundation · ~4 min read
FRONTEND & MOBILE · PLAYBOOK · FOUNDATION

Write Behavior-First Component Tests with Accessible Queries

AI will happily generate component tests that assert implementation trivia and snapshot dumps — green forever, catching nothing. Constrain generation to user-visible behavior through accessible queries — getByRole, getByLabelText, userEvent — and the same generator produces tests that survive refactors and double as an accessibility smoke check.

Tier: Foundation · Workflow area: Testing & quality

The test suite you get from an unconstrained "write tests for this component" prompt is worse than none: it cements the current implementation and objects to every refactor. The fix is not writing tests by hand — it is prompting with the testing philosophy baked in.

USE THIS WHEN

Behavior tests are the goal

  • Generating tests for interactive components — forms, dialogs, menus, tables with sorting/filtering.
  • Backfilling coverage on components you are about to refactor (behavior tests are the only ones that survive).
  • Setting the team template that every future generated test will imitate.
SKIP IT WHEN

The tool is wrong for the job

  • Pure logic (formatters, reducers, hooks without DOM) — plain unit tests, no DOM rendering needed.
  • Visual appearance — spacing, color, layout belong to visual regression, not RTL assertions on class names.
  • Full user journeys across pages — that is E2E territory (see the Playwright MCP playbook).

Prerequisites

  • React Testing Library + Vitest (or Jest) + @testing-library/user-event and @testing-library/jest-dom installed and configured.
  • The component renders in isolation (props mockable, network mocked via MSW if it fetches).
  • Five minutes with RTL's query priority in mind: role first, label second, text third, testid as the documented last resort.

The recipe

  1. Prompt with the constraint set, not just the request. The template that changes the output class:

    Write Vitest + React Testing Library tests for PaymentForm.tsx
    (attached, with its props type). Hard rules:
    - Queries: getByRole / getByLabelText / getByText only. findBy* for
      async appearance. No getByTestId unless you first explain why no
      accessible query can work. No container.querySelector. No snapshots.
    - Interactions: userEvent only (setup() pattern) — no fireEvent.
    - Assert what the USER can observe: visible text, accessible names,
      enabled/disabled state, focus location, what onSubmit was called
      with. Never assert state variables, class names, or child props.
    - Async: await findBy* or waitFor; no arbitrary timeouts.
    Cover: happy path, each validation error, server-error display,
    double-submit prevention, and keyboard-only submission.
    
  2. Read the generated queries as an accessibility report. This is the hidden payoff. If the model resorts to getByTestId('submit-btn'), ask why:

    You used getByTestId for the submit button. What accessible query
    would work, and what would the component need to change for it?
    

    The honest answer is often "the button has no accessible name" or "the field's label is not programmatically associated" — a real accessibility bug the test just found. Fix the component (<label htmlFor>, aria-label, real <button>), then use the role query. Every test-id in a generated suite is a lead worth pulling.

  3. Force the unhappy paths into existence. Generators gravitate to happy paths. A sample of what the constrained output should include:

    it('shows a validation error and keeps focus for correction', async () => {
      const user = userEvent.setup();
      render(<PaymentForm onSubmit={onSubmit} />);
      await user.type(screen.getByLabelText(/card number/i), '1234');
      await user.click(screen.getByRole('button', { name: /pay now/i }));
      expect(await screen.findByRole('alert')).toHaveTextContent(/card number is invalid/i);
      expect(onSubmit).not.toHaveBeenCalled();
    });
    

    Note what it asserts: the error is in a role="alert" (announced to screen readers), the callback did not fire, and the queries would survive a complete restyle. If generated error tests assert a CSS class instead of an alert role, the component may be the problem again — errors that are only a red border are invisible to assistive tech.

  4. Audit for decorative assertions before accepting. The three tells in generated suites: assertions that restate the setup (expect(screen.getByText('Pay now')).toBeInTheDocument() right after rendering a button labeled "Pay now" — true but content-free unless conditional rendering is the point), over-mocked internals (mocking a child component and asserting the mock rendered), and waitFor blocks with no assertion inside. Delete or strengthen each. A quick heuristic: for each test, ask "what plausible bug makes this fail?" No answer, no test.

  5. Prove the suite bites. Manually sabotage the component — remove the disabled-while-submitting logic, break a validation branch — and confirm a test fails each time. This is the two-minute manual version of mutation testing; the full version (StrykerJS) is the next playbook and this suite's real exam.

  6. Make the template contagious. Commit the best generated file as PaymentForm.test.tsx and extract the prompt into your instruction file (CLAUDE.md / Copilot instructions / Cursor rules) so every future "write tests" request inherits the constraints by default. The team's marginal cost of behavior-first testing drops to zero.

Note

getByRole failures come with a printout of every role in the rendered tree — read it. It is simultaneously your debugging tool and a miniature accessibility tree audit of the component.

Tool variations

  • Copilot in VS Code: inline generation per test after you write the first it() yourself as the pattern; put the hard rules in .github/copilot-instructions.md.
  • Claude / Claude Code: best for whole-suite generation from the component file plus the prompt above; Claude Code can run the suite and iterate on failures itself — make it show you the final run.
  • Vue / Svelte / Angular: Testing Library variants exist for all three with identical query philosophy; only the render call changes.
  • React Native: RTL for React Native flips the vocabulary — getByRole support is limited, so getByLabelText (accessibilityLabel) and getByText lead; the behavior-first rules are unchanged.

Quality check

  • Query disciplinezero querySelector, zero snapshots, and every testid accompanied by a comment explaining why role/label queries could not work.
  • Unhappy paths presentvalidation, server error, disabled/loading, and keyboard-only flows each have a test.
  • Assertions observableevery expectation names something a user or assistive tech could perceive, or a callback contract.
  • Sabotage test passedyou broke the component two ways and the suite went red both times.
  • Template institutionalizedthe constraint prompt lives in the repo's instruction file, not in someone's chat history.
Pro Tip

The accessible-query constraint is doing double duty: it makes tests refactor-proof and turns your test generator into a continuous accessibility linter. When AI cannot find a component by role or label, neither can a screen reader — treat every such failure as a component bug first and a test problem second.

This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in