Skip to main content
Published
TestingTesting LibraryAccessibility

Testing the experience, not the DOM

A practical Testing Library strategy: toBeVisible, userEvent, role queries, and getBy versus findBy versus queryBy. Built for product teams writing new tests.

Most brittle UI tests fail the same way. They assert on the DOM the way a developer inspects it, not the way a person uses the product. The fix is not more tests. It is better questions.

This is the testing strategy I coach teams toward with Testing Library and Playwright-style thinking: test the experience, prefer queries users can understand, and wait the way the UI actually loads. Apply it to new code first. Legacy suites can migrate without a big-bang rewrite.

From testing the DOM to testing the experience

An element can be in the document and still be useless to a user. It can be display: none, covered by a modal, or sitting at opacity 0. toBeInTheDocument() will still pass. That is a participation trophy.

Default positive assertions to visibility. If a sighted user cannot see it, your test should not celebrate it either. That lines up with accessibility and with how product bugs actually get reported.

  • Care that the user can see it → toBeVisible().
  • Only care that it exists in the tree → toBeInTheDocument().
  • Default to toBeVisible() for positive UI assertions.
errorBanner.test.tsxtsx
import { render, screen } from "@testing-library/react";
 
render(
  <div data-testid="error" style={{ display: "none" }}>
    Error!
  </div>
);
 
// Passes. The robot is happy. The user sees nothing.
expect(screen.getByTestId("error")).toBeInTheDocument();
 
// Fails. This is the assertion you usually want.
expect(screen.getByTestId("error")).toBeVisible();
Same fixture, two assertions. Only one matches what a human can see.

Interact like a person

fireEvent.click() is a robotic trigger. It skips focus, pointer behaviour, and the asynchronous path a real click takes. userEvent is denser and slower in a good way. It behaves closer to a human.

  • Retire fireEvent.click() for new interaction tests.
  • Use await userEvent.click() and friends by default.
submitButton.test.tsxtsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SaveForm } from "./SaveForm";
 
test("saves when the user clicks Save", async () => {
  const user = userEvent.setup();
  const onSave = vi.fn();
 
  render(<SaveForm onSave={onSave} />);
 
  await user.click(screen.getByRole("button", { name: /save/i }));
 
  expect(onSave).toHaveBeenCalledOnce();
});
Prefer userEvent for clicks, typing, and tab order.

Find elements the way users find them

getByTestId() is not evil. Users just never see it. If a test only works with a test id, ask whether a person could find the same control. Accessible names make better tests and better UI.

The query priority I ask teams to follow is simple and strict for new code.

  • getByRole() first. Gold standard for controls and landmarks.
  • getByLabelText() for form fields.
  • getByText() when the visible copy is the point.
  • getByTestId() last resort.
loginForm.test.tsxtsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
 
test("signs in with email and password", async () => {
  const user = userEvent.setup();
  render(<LoginForm />);
 
  await user.type(screen.getByLabelText(/email/i), "a@example.com");
  await user.type(screen.getByLabelText(/password/i), "correct-horse");
  await user.click(screen.getByRole("button", { name: /sign in/i }));
 
  expect(
    await screen.findByRole("status", { name: /signed in/i })
  ).toBeVisible();
});
If getByRole and getByLabelText work, you probably shipped accessible UI.

Know when to wait

Not everything exists on first paint. API calls, loading states, async updates, and animations all delay the UI. Tests that only use getBy assume the world is already ready. That creates flakes on async product flows.

Pick the query family based on timing, not habit.

  • getBy → must already exist. Fail fast if missing.
  • findBy → will appear later. Await it.
  • queryBy → might not exist. Returns null instead of throwing.
asyncUi.test.tsxtsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { GenerateCvButton } from "./GenerateCvButton";
 
test("shows an alert after generation completes", async () => {
  const user = userEvent.setup();
  render(<GenerateCvButton />);
 
  // Already present
  await user.click(screen.getByRole("button", { name: /generate/i }));
 
  // Appears after the async work
  expect(await screen.findByRole("alert")).toBeVisible();
 
  // Absence check
  expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument();
});
Ask: is this already on screen, or am I waiting for it?

How this maps onto product platforms

On high-traffic portals I still want unit tests close to logic, Testing Library around critical UI states, and Playwright or Cypress for a thin set of journeys: login, pay, upgrade, submit. The query rules above apply in component tests. E2E should also assert on roles and visible outcomes, not opaque selectors.

Coverage percentage is a lagging indicator. Stable tests that mirror user language are the leading one. On teams I have led, moving off test ids and fireEvent for new work cut flake noise without rewriting the whole suite in one sprint.

notes/testing-baseline.tsts
const testingBaseline = {
  unit: "pure logic, adapters, pricing rules",
  component: "Testing Library + userEvent + roles",
  e2e: "few journeys: auth, pay, critical submit",
  ciGate: ["typecheck", "unit", "component", "smoke e2e"],
};
A practical split for product CI.

Kill list for new code

Do not ban these forever in a legacy repo. Do stop reaching for them in new tests.

  • toBeInTheDocument() as the default positive assertion.
  • fireEvent.click() for user interactions.
  • getByTestId() as the default query.
  • getBy for UI that appears after async work.

Power-ups

Make these the default for new tests.

  • toBeVisible() for positive UI outcomes.
  • await userEvent.click() and userEvent.type().
  • getByRole() and getByLabelText() before anything else.
  • findBy when the UI appears later. queryBy for absence.

A one-sprint challenge

Find one test that uses getBy on async UI and switch it to findBy. Find one click path still on fireEvent and move it to userEvent. Find one toBeInTheDocument() that should be toBeVisible().

If the suite gets quieter, keep going. That is the strategy: more human tests, fewer robotic trophies.

More writing

Availability

Open to opportunities

Open to senior product engineer roles with real ownership across the stack. Security-minded by default.

UK-based · Remote / hybrid · Permanent or contract