Back to all posts

How to Test a Component Like a User

Pranay Kothapalli
Pranay Kothapalli

Maintainer at Rad UI

4 min read

A practical guide to testing accessibility, focus order, and event flow using @testing-library/react — no screenshots, no snapshots, just interaction truth.

How to Test a Component Like a User

Front-end testing often devolves into pixel voyeurism — staring at screenshots, comparing snapshots, pretending that if two trees of divs look the same, the experience must be fine.
It isn’t.

Real users don’t diff snapshots. They tab, click, type, and wait. They explore your interface through focus order, semantics, and events — not DOM markup. If your tests don’t reflect that, you’re testing an illusion.

This post is about writing tests that see your UI the way humans (and assistive tech) do — with interaction truth.


The Philosophy: Stop Testing Implementation, Start Testing Behavior

A component’s internal structure — its hooks, refs, and div nests — are implementation details. What matters is what it does when a user interacts with it.

@testing-library/react was built around that philosophy. Its guiding principle is “the more your tests resemble the way your software is used, the more confidence they can give you.”

That means:

  • Don’t query by className or id.
  • Don’t assert on DOM shape.
  • Don’t test internal states or hook outputs.

Instead, test what’s visible and interactive:


import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Dialog } from "@/components/dialog";

test("opens when the trigger is clicked", async () => {
  const user = userEvent.setup();
  render(<Dialog />);
  
  await user.click(screen.getByRole("button", { name: /open dialog/i }));
  expect(screen.getByRole("dialog")).toBeVisible();
});
`

This is a test you could almost read aloud: “When the user clicks the Open Dialog button, a dialog should appear.” That’s behavior. That’s truth.


Focus Order: The Hidden Backbone of Accessibility

A good test doesn’t just verify visibility; it ensures the focus order flows correctly.

When a modal opens, focus should move inside it. When it closes, focus should return to where it came from. This isn’t a nicety — it’s the only way keyboard users can navigate.


test("focus is trapped inside dialog", async () => {
  const user = userEvent.setup();
  render(<Dialog />);
  
  await user.click(screen.getByRole("button", { name: /open dialog/i }));
  
  // Tab through elements
  await user.tab();
  expect(screen.getByRole("textbox")).toHaveFocus();

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

  // Shift+Tab should loop
  await user.tab({ shift: true });
  expect(screen.getByRole("textbox")).toHaveFocus();
});

Here we’re not checking classes or order of elements — we’re testing navigation experience. A real user doesn’t care about DOM z-indexes; they care that pressing Tab just works.


Event Flow: Beyond Clicks

Clicks are easy. Event flow is hard. For example, pressing Enter on a button should behave like a click. Pressing Escape in a dialog should close it. Keyboard behavior defines accessibility as much as ARIA labels do.


test("Escape key closes dialog", async () => {
  const user = userEvent.setup();
  render(<Dialog />);
  await user.click(screen.getByRole("button", { name: /open dialog/i }));

  await user.keyboard("{Escape}");
  expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

These tests make no assumptions about your internal handlers. They care only about how the interface responds — which is precisely what a11y tools like VoiceOver or NVDA simulate.


Don’t Snapshot, Don’t Screenshot

Snapshots are tempting: instant gratification, zero thought. But they’re brittle. Change a class name, break a test. Add whitespace, break a test. None of it matters to users.

You want tests that break for the right reasons — broken interactions, lost focus, missing semantics. A green test suite should mean “my app still works,” not “my markup still looks vaguely familiar.”


Assert Semantics, Not Structure

Accessibility is semantic glue. Screen readers don’t “see” your HTML; they read roles and labels.

Use getByRole, getByLabelText, and getByText over selectors:


expect(screen.getByRole("dialog")).toHaveAccessibleName("Settings");
expect(screen.getByRole("button", { name: /save/i })).toBeEnabled();

This ensures that your component communicates intent, not just appearance. A component without a role is invisible to assistive tech — and that’s a failure of design, not style.


Write Tests as Narratives

A great test reads like a story:

“The user opens the menu, selects an option, and sees feedback.”

Tests are documentation for your UX contract — they define what “working” means. Future contributors can refactor your internals freely, as long as these stories still hold true.


Truth Over Coverage

100% coverage means nothing if it’s testing lies. Interaction truth — the faithful reproduction of user experience — is the only metric that matters.

When your tests:

  • Navigate like a keyboard user,
  • Read like a screen reader,
  • Click like a real person,

…then your component isn’t just tested. It’s trusted.


No screenshots. No snapshots. Just the truth of interaction.

Because that’s how users experience your app — one focus ring, one event, one intention at a time.