π What You Will Learn
- The Testing Library philosophy: test behavior users see, never implementation details
- Full setup: Vitest +
@testing-library/react+ jsdom + jest-dom matchers, config included - Queries mastery:
getByvsqueryByvsfindBy, and the role > label > text > testid priority - Realistic interactions:
userEventfor clicks, typing, and form submission - Async UI & mocking:
findBy*,waitFor,vi.fn(),vi.mock(), and mockingfetch - Hooks & providers:
renderHookfor custom hooks and a customrenderfor context - What NOT to test: coverage sanity, snapshot skepticism, and the mistakes that make suites brittle
Prerequisites: comfortable with hooks (React Hooks), forms (React Forms), and context (useContext). This is an Advanced post β but every step is madaling sundan, promise.
Why Test React Components At All?
Tests are not homework you do to please a code reviewer. Tests buy you one thing: confidence to change code. When your checkout form has 12 edge cases and a teammate refactors it next month, the test suite is what tells them β in 3 seconds β whether they broke anything. Walang tests? Every refactor becomes manual clicking through the whole app and praying.
But how you test matters as much as whether you test. The React ecosystem settled this debate years ago with the Testing Library philosophy:
Concretely, that means two very different tests for the same component:
// β Implementation-detail test: breaks when you rename state,
// switch useState β useReducer, or extract a child component
expect(component.state.isOpen).toBe(true);
expect(setStateSpy).toHaveBeenCalledTimes(2);
// β
Behavior test: survives ANY refactor that keeps the UX the same
await user.click(screen.getByRole("button", { name: /open menu/i }));
expect(screen.getByRole("menu")).toBeInTheDocument();Every tool in this post β Vitest, React Testing Library, userEvent β is built around that principle. Ang importante dito: you are testing the contract your component offers users, not the wiring inside it.
Setup: Vitest + Testing Library + jsdom
Vitest is the natural test runner for a Vite React app β same config, same transforms, instant watch mode. It also works great in Next.js projects. Four packages do all the work:
| Package | Role |
|---|---|
vitest | The test runner: describe / it / expect / vi, watch mode, coverage |
@testing-library/react | render(), screen, queries, renderHook β the React bindings |
jsdom | A fake browser DOM so components can render in Node |
@testing-library/jest-dom | Readable matchers: toBeInTheDocument, toHaveValue, toBeDisabled⦠|
@testing-library/user-event | Simulates real users: click, type, tab, paste (v14: all async) |
npm install -D vitest jsdom @vitejs/plugin-react \
@testing-library/react @testing-library/jest-dom @testing-library/user-eventimport { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
// Components need a DOM β jsdom fakes one inside Node
environment: "jsdom",
// Auto-import describe/it/expect in every test file
globals: true,
// Runs once before each test file (matchers + cleanup live here)
setupFiles: "./src/test/setup.ts",
css: true,
},
});// Registers matchers like toBeInTheDocument() on Vitest's expect
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// Unmount rendered components after every test so tests never leak
// DOM into each other
afterEach(() => {
cleanup();
});{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}npm test starts watch mode β it re-runs only the tests affected by the file you just saved. npm run test:run is the CI-friendly single pass.
globals: true, add "types": ["vitest/globals"] to your tsconfig.json compilerOptions β otherwise TypeScript will red-underline every describe and expect even though the tests run fine.Your First Test: render + screen
The smallest possible pair: a component and the test that proves what it renders. Two functions carry the whole workflow β render() mounts the component into jsdom, and screen queries the resulting DOM like a user reading the page.
type GreetingProps = {
name: string;
};
export function Greeting({ name }: GreetingProps) {
return <h1>Hello, {name}!</h1>;
}import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { Greeting } from "./Greeting";
describe("Greeting", () => {
it("greets the user by name", () => {
// 1. Arrange: mount the component into the fake DOM
render(<Greeting name="Thirdy" />);
// 2. Assert: find it the way a user would β by visible text
expect(screen.getByText("Hello, Thirdy!")).toBeInTheDocument();
});
});Run npm test and Vitest picks up any file matching *.test.tsx:
β src/components/Greeting.test.tsx (1 test) 24ms
β Greeting > greets the user by name
Test Files 1 passed (1)
Tests 1 passed (1)screen instead of destructuring render()? render() does return queries, but the screen object always points at the whole document β one consistent import, no juggling return values, and it is what the official docs recommend. Muscle memory: render then screen, always.Queries Deep Dive: getBy vs queryBy vs findBy
Every query is a combination of a variant (what happens when nothing matches) and a selector (how you locate the element). Getting these two axes into your head is 80% of Testing Library fluency.
The variants β same selector, three failure behaviors:
| Variant | 0 matches | 1 match | Use it for |
|---|---|---|---|
getBy* | π₯ throws | returns element | Elements that MUST be there right now |
queryBy* | returns null | returns element | Asserting something is NOT rendered |
findBy* | retries ~1s, then rejects | resolves element | Elements that appear asynchronously (await it!) |
The selectors β in strict priority order. The higher on this list, the more your test resembles a real user (and a screen reader):
| Priority | Query | Finds elements by⦠|
|---|---|---|
| 1οΈβ£ Best | getByRole | Accessible role + name: getByRole("button", { name: /save/i }) |
| 2οΈβ£ | getByLabelText | The <label> of a form field β how users find inputs |
| 3οΈβ£ | getByPlaceholderText | Placeholder text (only when there is no label β fix the label instead!) |
| 4οΈβ£ | getByText | Visible text content β great for non-interactive elements |
| 5οΈβ£ | getByDisplayValue | The current value of an input |
| 6οΈβ£ Last resort | getByTestId | data-testid attribute β invisible to users, use only when nothing else works |
Theory is one thing β seeing which query grabs which element is another. The demo below visualizes it inline: pick a query, watch it highlight its target in the rendered card, and see exactly what each variant does when the element does not exist.
Pick a query and see which element it targets in the rendered card below β and what happens when nothing matches:
Welcome back
(Note: there is no "Log out" text anywhere in this card)
getByText("Log out") to check that a logged-out user does not see the logout button. getBy* throws on zero matches, so the test fails with a confusing error instead of a clean assertion. Absence checks are expect(screen.queryByText("Log out")).toBeNull() β always queryBy*.Simulating Users with userEvent
Testing Library ships two ways to interact: fireEvent (low-level, dispatches one raw DOM event) and userEvent (high-level, simulates the full sequence a browser fires). Prefer userEvent, always:
await user.click(button)firespointerdown,mousedown,focus,pointerup,mouseup, thenclickβ exactly like a real browserawait user.type(input, "hello")presses each key one at a time, firingkeydownβinputβkeyupper character- It respects reality: it refuses to click a
disabledbutton or type into areadOnlyinput β just like your users physically can't
The workflow in v14: create a user session with userEvent.setup() before rendering, then await every interaction. Here is the classic Counter, its real test file, and β since Vitest can't run in your browser β a live assertion visualizer: the component is real, you click it, and the panel shows which of the test's assertions would pass at this exact moment.
This is the real Counter under test. The test clicks 3 times then asserts. Click the button yourself and watch each assertion flip live:
Count: 0
await on user.click(...) means your assertion runs before the click finishes β the test fails randomly, or worse, passes for the wrong reason and you get an act(...) warning in the output. If you ever see "not wrapped in act", the first suspect is a missing await.Testing Forms & Validation
Forms are where component tests earn their salary β validation rules are exactly the kind of logic that silently breaks during refactors. The recipe never changes:
- 1Find fields by labelβ
getByLabelText(/email/i) - 2Fill them like a userβ
await user.type(field, "value") - 3Submit via the buttonβ
await user.click(getByRole("button")) - 4Assert what rendersβ error messages present with
getByText, absent withqueryByText
Below is a real signup form with its full test file in the Code tab. The Demo tab visualizes the assertions inline β as you type and submit, the "test status" panel flips each expectation between β and β so you can literally watch the test think:
The test types [email protected], a short password, and clicks Create account. Play the user yourself β the panel below shows each assertion passing or failing as you type and submit:
setEmail, no reading state, no checking that submitted === true. They type, click, and read the screen. Tomorrow you could rewrite this form with useReducer or React Hook Form and β as long as the labels, button, and messages stay β every test still passes. That is the whole philosophy paying off.Async UI: findBy, waitFor & Loading States
Real components fetch. That means your test renders a loading state first, and the data appears one microtask (or several) later. Beginners reach for setTimeout sleeps β don't. Testing Library has proper async tools:
| Tool | What it does | When to use |
|---|---|---|
await screen.findByText(...) | Retries the query every 50ms until it matches (default timeout 1000ms) | Waiting for ONE element to appear |
await waitFor(() => {...}) | Retries a whole callback of assertions until none throw | Multiple assertions, or non-query checks (mock call counts) |
waitForElementToBeRemoved(...) | Resolves when the element disappears | Asserting a spinner went away |
The component below fetches a user on mount (note the cleanup flag in the effect β cancelled fetches must not set state). The demo visualizes the async assertions inline: mount it, and watch findByText sit in β³ while loading, then flip to β
the moment the profile renders.
In the real test, render() kicks off the fetch. Here, press Mount component to simulate it, then watch findByText go from waiting to passing:
findByText("Alice") is literally waitFor(() => getByText("Alice")) under the hood. That is why it must be awaited, and why it is the single most useful query for anything that loads: no fake timers, no sleeps, no flaky race conditions β it just polls until React finishes rendering the data.Mocking: vi.fn(), vi.mock() & Fetch
Unit tests must be isolated: no real network, no real clock, no shared server state. Vitest gives you three levels of mocking, from smallest to biggest hammer:
The most common case: your component receives an onSave-style callback, and the test verifies it was called with the right arguments.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { TodoItem } from "./TodoItem";
describe("TodoItem", () => {
it("calls onDelete with the todo id", async () => {
const user = userEvent.setup();
const handleDelete = vi.fn(); // a spy: records every call it receives
render(
<TodoItem
todo={{ id: "t-42", title: "Ship the tests" }}
onDelete={handleDelete}
/>
);
await user.click(screen.getByRole("button", { name: /delete/i }));
expect(handleDelete).toHaveBeenCalledTimes(1);
expect(handleDelete).toHaveBeenCalledWith("t-42");
});
});When your component imports an API module directly, mock the module so the component-under-test never knows the difference:
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { OrderList } from "./OrderList";
import { fetchOrders } from "./api/orders";
// Hoisted to the top of the file β every import of ./api/orders
// now receives auto-mocked (vi.fn) exports instead of the real ones
vi.mock("./api/orders");
describe("OrderList", () => {
it("renders the orders returned by the API", async () => {
// Program the mock's response for THIS test
vi.mocked(fetchOrders).mockResolvedValue([
{ id: "o-1", item: "Mechanical keyboard", total: 4500 },
{ id: "o-2", item: "USB-C hub", total: 1200 },
]);
render(<OrderList />);
expect(await screen.findByText("Mechanical keyboard")).toBeInTheDocument();
expect(screen.getByText("USB-C hub")).toBeInTheDocument();
expect(fetchOrders).toHaveBeenCalledTimes(1);
});
it("shows an error state when the API fails", async () => {
vi.mocked(fetchOrders).mockRejectedValue(new Error("Network down"));
render(<OrderList />);
expect(
await screen.findByText(/could not load orders/i)
).toBeInTheDocument();
});
});import { vi, beforeEach, afterEach } from "vitest";
beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ name: "Alice Santos" }),
})
);
});
afterEach(() => {
vi.unstubAllGlobals(); // never leak a stubbed fetch into other tests
});fetch works for a handful of tests, but once many components hit many endpoints, look at Mock Service Worker (msw). It intercepts requests at the network level, so your components use the real fetch code path and you define handlers per endpoint once, reused across the whole suite. Same tests, far less stubbing.Testing Custom Hooks with renderHook
Hooks can't be called outside a component β so how do you test useToggle without building a throwaway UI? renderHook mounts your hook inside an invisible test component and hands you result.current: a live window into whatever the hook returns. State updates go inside act(...) so React flushes the re-render before you assert.
renderHook runs a hook with no UI at all β the test reads result.current. Each button press below is one act(() => result.current.toggle()):
result.current = { on: false, toggle: [Function] }A rule of thumb for when to use it: if the hook is used by many components (a useDebounce, a useLocalStorage), test the hook directly with renderHook. If it exists to serve one component, just test that component β the hook gets covered for free through real behavior.
Testing Context & Providers
The first time you test a component that calls useAuth() or useTheme(), it explodes: Error: useAuth must be used inside <AuthProvider>. The fix is the wrapper option β render the component inside the providers it expects:
import { render, screen } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import { AuthProvider } from "./AuthContext";
import { WelcomeBanner } from "./WelcomeBanner";
it("greets the signed-in user", () => {
render(<WelcomeBanner />, {
// Everything render() mounts gets wrapped in this tree
wrapper: ({ children }) => (
<AuthProvider initialUser={{ name: "Thirdy" }}>{children}</AuthProvider>
),
});
expect(screen.getByText(/welcome back, thirdy/i)).toBeInTheDocument();
});Doing that in every file gets old fast. The production pattern is a custom render: one test-utils.tsx that wraps all your app-wide providers, re-exported so tests import it instead of the library:
import { render as rtlRender, RenderOptions } from "@testing-library/react";
import { ReactElement, ReactNode } from "react";
import { ThemeProvider } from "@/context/ThemeContext";
import { AuthProvider } from "@/context/AuthContext";
type User = { name: string } | null;
function AllProviders({
children,
user = null,
}: {
children: ReactNode;
user?: User;
}) {
return (
<ThemeProvider>
<AuthProvider initialUser={user}>{children}</AuthProvider>
</ThemeProvider>
);
}
// Custom render: same signature, but pre-wrapped in every app provider
export function renderWithProviders(
ui: ReactElement,
{ user, ...options }: RenderOptions & { user?: User } = {}
) {
return rtlRender(ui, {
wrapper: ({ children }) => <AllProviders user={user}>{children}</AllProviders>,
...options,
});
}
// Re-export everything else so tests only ever import from test-utils
export * from "@testing-library/react";import { renderWithProviders, screen } from "@/test/test-utils";
import { it, expect } from "vitest";
import { WelcomeBanner } from "./WelcomeBanner";
it("greets the signed-in user", () => {
renderWithProviders(<WelcomeBanner />, { user: { name: "Thirdy" } });
expect(screen.getByText(/welcome back, thirdy/i)).toBeInTheDocument();
});
it("shows the login prompt when signed out", () => {
renderWithProviders(<WelcomeBanner />); // user defaults to null
expect(screen.getByRole("link", { name: /log in/i })).toBeInTheDocument();
});test-utils.tsx once and every existing test keeps working. This single file is the highest-leverage piece of test infrastructure you will write.Coverage & What NOT to Test
Enable coverage to find the untested branches that matter β the error path nobody exercised, the empty-list state:
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: "./src/test/setup.ts",
coverage: {
provider: "v8",
reporter: ["text", "html"],
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/**/*.test.{ts,tsx}", "src/test/**"],
},
},
});But treat the percentage as a flashlight, not a KPI. Chasing 100% pushes you into testing things that give zero confidence. Skip these on purpose:
- Implementation details β internal state values, how many times a component re-rendered, which hook it uses. Refactors change these while behavior stays identical.
- Styles and layout β asserting a Tailwind class string or a pixel value tests your CSS framework, not your logic. Visual bugs belong to visual regression tools, not unit tests.
- Third-party internals β React Hook Form validates, your router routes, your UI kit renders. Test your integration with them (the error message shows), never their machinery.
- Trivial pass-throughs β a component that renders one prop into one tag has nothing to break.
expect(container).toMatchSnapshot() feels productive β one line, whole component "covered". In practice big snapshots fail on every harmless markup change, developers learn to press u to update without reading the diff, and the test degenerates into a rubber stamp. If a snapshot must exist, keep it tiny and focused (one element, not a page). A handful of explicit getByRole assertions communicates intent far better than a 300-line HTML dump.Common Mistakes
getByRole can't find your button, a screen reader can't either. Reaching for data-testid first skips the free accessibility audit that role/label queries give you.// β BAD: invisible to users, tells you nothing about accessibility
await user.click(screen.getByTestId("submit-btn"));
// β
GOOD: if this fails, your button is ALSO broken for screen readers
await user.click(screen.getByRole("button", { name: /submit/i }));user.click() or findByText() lets the assertion run mid-interaction. The test passes on your machine, fails in CI, and everyone blames the runner. Every userEvent call and every findBy* gets an await β no exceptions. ESLint's eslint-plugin-testing-library catches this automatically β add it.// β BAD: assertion races the click β passes or fails at random
user.click(screen.getByRole("button", { name: /add/i }));
expect(screen.getByText("1 item")).toBeInTheDocument();
// β
GOOD: the click fully completes before the assertion runs
await user.click(screen.getByRole("button", { name: /add/i }));
expect(screen.getByText("1 item")).toBeInTheDocument();true couples the test to today's implementation. Assert what renders β that survives the switch from useState to useReducer to a store.// β BAD: reaches into internals (needs hacks to even do this)
expect(getComponentState().isModalOpen).toBe(true);
// β
GOOD: asserts the thing the user actually experiences
await user.click(screen.getByRole("button", { name: /delete account/i }));
expect(
screen.getByRole("dialog", { name: /are you sure/i })
).toBeInTheDocument();// β BAD: 300 lines of HTML that fails on every class rename
expect(container).toMatchSnapshot();
// β
GOOD: three assertions that state exactly what matters
expect(screen.getByRole("heading", { name: /order summary/i })).toBeInTheDocument();
expect(screen.getByText("β±1,250.00")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /place order/i })).toBeEnabled();Best Practices Checklist + Reference
getByTestId is the last resort, not the defaultuserEvent.setup() and an await on every interactionrenderWithProviders) that carries all app providerseslint-plugin-testing-library β it flags missing awaits and anti-pattern queries automaticallyQuery cheat sheet β the grid to keep beside your editor:
| Goal | Query to use | Example |
|---|---|---|
| Button / link / heading | getByRole | getByRole("button", { name: /save/i }) |
| Form input | getByLabelText | getByLabelText(/email/i) |
| Static visible text | getByText | getByText(/welcome back/i) |
| Element that appears later | await findByText | await findByText("Alice Santos") |
| Element that must NOT exist | queryByText + toBeNull | expect(queryByText(/error/i)).toBeNull() |
| Several similar items | getAllByRole | getAllByRole("listitem") // assert .length |
Matchers you will use daily (from vitest + jest-dom):
| Matcher | Asserts that⦠|
|---|---|
toBeInTheDocument() | The element is rendered in the DOM |
toHaveValue("x") | An input currently holds this value |
toHaveTextContent(/x/) | The element's text matches |
toBeDisabled() / toBeEnabled() | Interactive element state |
toBeVisible() | Rendered AND not hidden by CSS |
toHaveBeenCalledWith(...) | A vi.fn() mock received these exact arguments |
toHaveBeenCalledTimes(n) | A vi.fn() mock was called exactly n times |
toBeNull() | A queryBy* found nothing β the absence assertion |
Practice Project + What's Next
Cement all of it by test-driving a searchable contact list:
- A
ContactListthat fetches contacts on mount β test the loading state withgetByText, the loaded list withfindAllByRole("listitem"), and the error state with a rejected mock - A search input (found via
getByLabelText) βawait user.type(...)and assert filtered results, plusqueryByTextfor the ones filtered out - An "Add contact" form β validation errors on empty submit, success path, and a
vi.fn()assertingonAddgot the right payload - A
useContactSearchcustom hook β test it directly withrenderHook+act - Wrap it all with a
renderWithProvidersif you keep contacts in context
If every test still passes after you refactor the list from useState to useReducer β congrats, you wrote behavior tests. Yan ang goal.
- Custom Hooks β extract more reusable logic, then point
renderHookat it - React Forms β controlled inputs and validation patterns, the #1 thing worth testing
- Data Fetching β loading/error/success states that map 1:1 to the async tests you just learned
Keep practicing! πͺ The first week of testing feels slower than just clicking around. By week three, you refactor a gnarly form, the suite goes green in four seconds, and you ship without opening the browser once β that feeling is why every senior React engineer tests.