Keyboard Navigation Testing with user-event

A control that a mouse can reach but a keyboard cannot is invisible to a large population of users, and it is exactly the kind of defect that unit tests and even role queries slide right past. This guide shows how to prove keyboard operability with @testing-library/user-event v14: pressing Tab to walk the focus order, arrow keys to move within composite widgets, and Enter or Space to activate a control, asserting on document.activeElement at each step. It is for React developers testing components in Vitest with React Testing Library who want the tab order and activation behavior their sighted keyboard users depend on to be pinned down by tests rather than discovered in a bug report.

Root Cause Analysis

Keyboard operability breaks in ways that are invisible to the two testing habits most teams already have. The first habit is fireEvent.click, which dispatches a synthetic click directly on an element regardless of whether that element is focusable or reachable by Tab. A <div onClick> responds to fireEvent.click exactly like a real button, so the test passes while a keyboard user, who can never move focus to that div, is completely blocked. The gap is structural: fireEvent simulates the outcome of an interaction, not the interaction itself, so it cannot reveal that the path to the outcome does not exist for a keyboard.

The second habit is querying by role, which proves a control is in the accessibility tree but not that it is operable within it. A button can have a perfect role and accessible name and still be unreachable because a parent set tabindex="-1", or unusable because its custom key handler listens only for click and never for Enter. user-event closes both gaps by modeling the real input pipeline: user.tab() moves focus following the document’s tabbability rules — skipping disabled and tabindex="-1" elements, respecting DOM order — and user.keyboard('{Enter}') dispatches the full keydown/keypress/keyup sequence a real key produces. Because it drives focus and fires the complete event set, a test written with it fails whenever the keyboard path is broken, which is the whole point.

fireEvent bypasses the input path that user-event models fireEvent dispatches a click straight to the handler regardless of focusability, while user-event moves focus first and fires the full key sequence, catching unreachable controls. fireEvent.click — jumps straight to the handler fireEvent synthetic click onClick fires test passes anyway skips focusability entirely user.tab() + keyboard — follows the real path user.tab() moves focus focusable? gate {Enter} activates or the test fails
fireEvent reaches the handler without checking focusability; user-event follows the real keyboard path and fails when it is broken.

Reproducible Setup

Install user-event v14 alongside your Testing Library stack. The v14 API is promise-based and requires a setup() call per test, which also enables realistic timing.

npm install -D @testing-library/user-event

The component under test is a small menu that opens on button click and moves selection with the arrow keys — the archetypal composite widget where keyboard support is easy to get wrong.

// ActionsMenu.tsx
import { useRef, useState } from 'react';

const items = ['Rename', 'Duplicate', 'Delete'];

export function ActionsMenu({ onChoose }: { onChoose: (i: string) => void }) {
  const [open, setOpen] = useState(false);
  const [active, setActive] = useState(0);
  const refs = useRef<(HTMLButtonElement | null)[]>([]);

  return (
    <div>
      <button aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(true)}>
        Actions
      </button>
      {open && (
        <ul role="menu" aria-label="Actions">
          {items.map((label, i) => (
            <li role="none" key={label}>
              <button
                role="menuitem"
                ref={(el) => (refs.current[i] = el)}
                tabIndex={i === active ? 0 : -1}
                onKeyDown={(e) => {
                  if (e.key === 'ArrowDown') {
                    const next = (i + 1) % items.length;
                    setActive(next);
                    refs.current[next]?.focus();
                  }
                }}
                onClick={() => onChoose(label)}
              >
                {label}
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Implementation

Step 1 — Set up user-event once per test. The v14 setup() returns a user instance whose methods are all async; always await them so focus and events settle before you assert.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ActionsMenu } from './ActionsMenu';

test('reaches the trigger by Tab', async () => {
  const user = userEvent.setup();
  render(<ActionsMenu onChoose={() => {}} />);

  await user.tab();
  expect(screen.getByRole('button', { name: /actions/i })).toHaveFocus();
});

Step 2 — Walk the full tab order and assert each stop. Tabbing repeatedly and checking activeElement after each press verifies both that every interactive element is reachable and that the order matches the visual and reading order.

test('tab order visits controls in reading order', async () => {
  const user = userEvent.setup();
  render(<LoginForm />);

  await user.tab();
  expect(screen.getByRole('textbox', { name: /email/i })).toHaveFocus();
  await user.tab();
  expect(screen.getByLabelText(/password/i)).toHaveFocus();
  await user.tab();
  expect(screen.getByRole('button', { name: /sign in/i })).toHaveFocus();
});

Step 3 — Activate with the correct keys. Buttons must respond to both Enter and Space; links respond to Enter. Press the key and assert the handler ran, which proves the control is operable and not merely focusable.

test('trigger opens the menu with Enter', async () => {
  const user = userEvent.setup();
  render(<ActionsMenu onChoose={() => {}} />);

  await user.tab();
  await user.keyboard('{Enter}');
  expect(screen.getByRole('menu', { name: /actions/i })).toBeInTheDocument();
});

Step 4 — Drive arrow-key navigation inside the widget. Composite widgets use a roving tabindex: only the active item is tabbable, and arrow keys move focus among siblings. Assert that the arrow moves activeElement to the next menuitem.

test('arrow key moves focus to the next menu item', async () => {
  const user = userEvent.setup();
  render(<ActionsMenu onChoose={() => {}} />);

  await user.tab();
  await user.keyboard('{Enter}');
  // first item is focused when the menu opens
  await user.keyboard('{ArrowDown}');
  expect(screen.getByRole('menuitem', { name: /duplicate/i })).toHaveFocus();
});
Roving tabindex moving focus through menu items Only the active menu item has tabindex zero; pressing ArrowDown shifts the tabindex-zero item and moves focus to the next item. Roving tabindex on ArrowDown before Rename — tabindex 0 (focus) Duplicate — tabindex -1 Delete — tabindex -1 after ArrowDown Rename — tabindex -1 Duplicate — tabindex 0 (focus) Delete — tabindex -1 ArrowDown
Arrow keys move the single tabindex-zero item; the test asserts focus lands on the next menuitem.

Step 5 — Verify Shift+Tab and Escape where they matter. Backward navigation and dismissal are part of the contract. user.tab({ shift: true }) walks the order in reverse, and {Escape} should close a menu or dialog and return focus sensibly.

test('Shift+Tab returns to the previous control', async () => {
  const user = userEvent.setup();
  render(<LoginForm />);

  await user.tab();
  await user.tab();
  await user.tab({ shift: true });
  expect(screen.getByRole('textbox', { name: /email/i })).toHaveFocus();
});

Verification

Prove the tests guard real operability by breaking the keyboard path deliberately. Change the menu trigger from a <button> to a <div onClick> and run the suite: the await user.tab() step will leave focus on document.body instead of the trigger, so the toHaveFocus() assertion fails — the exact regression a keyboard user would hit. Restore the button and it passes. Then delete the onKeyDown arrow handler and confirm the arrow-key test fails because focus never moves. Each deliberate breakage that turns the suite red is evidence the test is exercising the keyboard path rather than a synthetic shortcut.

npx vitest run ActionsMenu.test.tsx

As a sanity check during development, log document.activeElement?.outerHTML after each user.tab() to watch focus move; the sequence you see is the exact order a keyboard user traverses, which makes it the quickest way to confirm your expected order before committing the assertions.

The mental model to hold while writing these tests is a linear tape: each user.tab() advances focus by one stop along the document’s tab order, and your assertions are checkpoints along that tape. When the rendered order matches the reading order, the checkpoints pass in sequence; when a control is inserted out of order — a modal trigger that visually sits last but is early in the DOM, say — the checkpoint that expected a different element fails, surfacing a real navigation surprise before a user hits it. Writing the whole walk as an explicit sequence, rather than jumping straight to the control you care about, is what makes the order itself part of the contract.

Sequential tab order as a series of assertion checkpoints Each Tab press advances focus one stop along the order, and a test asserts the active element at each stop from email to password to the submit button. Each Tab is a checkpoint Email stop 1 Password stop 2 Sign in stop 3 Tab Tab toHaveFocus() at each stop pins the order to the reading order
Asserting focus at every Tab stop makes the tab order itself part of the tested contract.

Troubleshooting

Symptom: toHaveFocus() fails even though the element looks focusable. Diagnosis: an ancestor or the element itself has tabindex="-1", or the element is disabled, so user.tab() correctly skips it. Fix: remove the negative tabindex from anything that should be in the tab order, and if the element is a custom control, ensure it is a real <button> or carries tabindex="0"; the failing test is reporting a genuine reachability defect.

Symptom: Enter or Space does nothing in the test. Diagnosis: the control is a non-button element whose handler listens only for onClick, so no key handler fires. Fix: use a native <button> which activates on both keys for free, or add explicit onKeyDown handling for Enter and Space; do not paper over it with fireEvent.click, which would hide the same bug from real users.

Symptom: focus assertions are flaky in async components. Diagnosis: focus moves after a state update or fetch that has not settled when you assert. Fix: await the user-event call, wait for a stable element with findByRole, resolve any request with MSW v2, and keep the act() boundary clean so focus has landed before the assertion runs.

FAQ

Why use user-event instead of fireEvent for keyboard tests?

fireEvent dispatches a single synthetic event straight to the target without regard for whether the element could actually receive it, so a fireEvent.click on an unfocusable <div> passes while a keyboard user is blocked. user-event models the real browser input pipeline: it moves focus following tabbability rules, fires the complete keydown/keypress/keyup sequence, and respects disabled and tabindex="-1" state. That fidelity is exactly what lets a keyboard test fail when the keyboard path is broken, which is the entire reason to write one. Reserve fireEvent for the rare low-level event you cannot express through user-event.

How do I test a roving-tabindex widget correctly?

A roving-tabindex composite keeps exactly one child at tabindex="0" and the rest at tabindex="-1", so Tab enters and leaves the whole widget as a single stop while arrow keys move focus among the children. Test it by tabbing to the widget, asserting the active child has focus, then pressing arrow keys and asserting focus moves to the correct sibling each time. Also confirm that Tab from inside the widget jumps out to the next component rather than cycling internally, since that out-and-in behavior is the defining property of the pattern.

Do I need fake timers for user-event delays?

Usually not. By default user-event v14 advances real time between keystrokes, which is fine for the vast majority of tests and keeps them realistic. You only need to coordinate with fake timers when the component itself schedules work on a timer — a debounce, a delayed tooltip — in which case pass userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) so the library drives your fake clock. For plain focus-order and activation tests, real timers and a normal setup() are simpler and sufficient.

Should keyboard tests replace manual screen-reader testing?

No. Keyboard operability is a necessary condition for accessibility but not a sufficient one, and these tests verify only the operability half. They confirm focus reaches every control and activation keys work, but they cannot tell you whether the announcements a screen reader makes are meaningful, whether the reading order is logical, or whether a live region updates at the right moment. Keep automated keyboard tests as a fast regression guard on every pull request, and pair them with periodic hands-on testing using an actual screen reader for the judgment-dependent parts.