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.
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();
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.
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();
});
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.
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();
});
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.
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();
});
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.
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"],
};
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() anduserEvent.type(). getByRole()andgetByLabelText()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.