Testing React components
Test what a user can see and click, not internal state or class names. Build a live save-link form, then test it with Testing Library like a real user.

A test that pokes at a component's internal state, or checks for a specific CSS class, breaks the moment you refactor the component, even if nothing a user experiences actually changed. That's a test getting in your way instead of helping you. React Testing Library takes a different angle: query the page the way a person would, by the text they'd read and the roles they'd interact with, and assert on what actually happened on screen.
The component: a save-link form
Here's a small form for saving a link, close to what you'd build for Linkstash's UI. Try it, type a URL and title, and hit save.
Fill in both fields and click save. A confirmation line appears. Delete the title and click save again. Nothing happens, because the guard clause if (!url || !title) return stops it. Those two behaviors, not the component's internal useState calls, are what the test should check.
Testing it: query like a user, not like a developer
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { SaveLinkForm } from "./SaveLinkForm.tsx";
describe("SaveLinkForm", () => {
it("calls onSave with the entered url and title", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<SaveLinkForm onSave={onSave} />);
await user.type(screen.getByLabelText("URL"), "https://logicdecode.com");
await user.type(screen.getByLabelText("Title"), "Logic Decode");
await user.click(screen.getByRole("button", { name: "Save link" }));
expect(onSave).toHaveBeenCalledWith({ url: "https://logicdecode.com", title: "Logic Decode" });
expect(screen.getByRole("status")).toHaveTextContent('Saved "Logic Decode"');
});
it("does not save when the title is empty", async () => {
const user = userEvent.setup();
const onSave = vi.fn();
render(<SaveLinkForm onSave={onSave} />);
await user.type(screen.getByLabelText("URL"), "https://logicdecode.com");
await user.click(screen.getByRole("button", { name: "Save link" }));
expect(onSave).not.toHaveBeenCalled();
});
});screen.getByLabelText("URL") finds the input the same way a screen reader would, through its associated <label>, not through a test-only data-testid attribute. screen.getByRole("button", { name: "Save link" }) does the same for the button. Neither line cares what the component's internals look like. Rewrite SaveLinkForm from useState to a reducer, rename every internal variable, and this test doesn't need to change, because nothing about what a user sees or clicks changed either.
Where the mocking lesson comes back
Notice onSave is vi.fn(), the same tool from mocks, stubs and fakes. The component doesn't know or care whether onSave posts to /links for real or just gets recorded by a spy. That's the same boundary principle from that lesson: mock the thing outside what you're testing (the network call a real onSave would make), never the thing you're testing (the form's own logic).
getByRole over getByTestId
Reach for getByRole, getByLabelText, and getByText first. They fail your test if the markup becomes inaccessible, for example if a button loses its visible label, which is useful information a data-testid would silently hide. Save getByTestId for the rare element with no meaningful role or text, like a purely decorative wrapper div.
When the update isn't instant
SaveLinkForm above updates synchronously, so getByRole("status") finds the confirmation the moment it renders. A real save button that posts to /links first and shows the confirmation after the response comes back needs a different query. getByRole throws immediately if the element isn't there yet, which is the wrong tool for something that appears a moment later. findByRole is the async version: it retries for a short window before giving up, which is exactly what you need when a component's UI depends on a promise resolving, the same kind of timing this series covered in testing async code without flakes.
expect(await screen.findByRole("status")).toHaveTextContent('Saved "Logic Decode"');Same idea as awaiting a promise in a plain async test, just wrapped in a query that knows how to wait for the DOM instead of a return value.
Why this catches real regressions
The point isn't philosophical purity. It's that a test written this way fails for the reasons that actually matter to a user, and passes through every internal refactor that doesn't. If a future teammate renames the title state variable to linkTitle, both tests above keep passing, correctly, because nothing a user experiences moved. If someone accidentally removes the guard clause and empty titles start saving, the second test catches it immediately, because it's checking behavior, not implementation.
You saw the same idea back in the React fundamentals post: a component is a function of its props and state, rendering to something a user sees. Testing Library just holds you to testing that output, not the machinery producing it.
Previous: code coverage and the lies it tells. Next: tests that run on every push, the last lesson in this series.

Written by
Rhythm Bhiwani
Engineer and relentless builder, happiest reverse-engineering hard problems until they click.
Enjoyed this?
Tap the heart to leave some love.
Be the first to react
Comments
Join the conversation.
Loading comments…


