React

Testing React with Vitest & Testing Library

Thirdy Gayares
16 min read

πŸŽ“ 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: getBy vs queryBy vs findBy, and the role > label > text > testid priority
  • Realistic interactions: userEvent for clicks, typing, and form submission
  • Async UI & mocking: findBy*, waitFor, vi.fn(), vi.mock(), and mocking fetch
  • Hooks & providers: renderHook for custom hooks and a custom render for 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.

1

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:

πŸ’‘ The guiding principle: "The more your tests resemble the way your software is used, the more confidence they can give you." Test what the user sees and does β€” rendered text, buttons, form fields β€” never internal state, hook call order, or component instance methods. If a refactor keeps the same behavior, your tests should keep passing.

Concretely, that means two very different tests for the same component:

philosophy-comparison.test.tsx
// ❌ 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.

2

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:

PackageRole
vitestThe test runner: describe / it / expect / vi, watch mode, coverage
@testing-library/reactrender(), screen, queries, renderHook β€” the React bindings
jsdomA fake browser DOM so components can render in Node
@testing-library/jest-domReadable matchers: toBeInTheDocument, toHaveValue, toBeDisabled…
@testing-library/user-eventSimulates real users: click, type, tab, paste (v14: all async)
1Install the dev dependencies
terminal
npm install -D vitest jsdom @vitejs/plugin-react \
  @testing-library/react @testing-library/jest-dom @testing-library/user-event
2Configure Vitest
vitest.config.ts
import { 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,
  },
});
3Create the setup file
src/test/setup.ts
// 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();
});
4Add the npm scripts
package.json
{
  "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.

⚠️ TypeScript gotcha: with 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.
3

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.

Greeting.tsx
type GreetingProps = {
  name: string;
};

export function Greeting({ name }: GreetingProps) {
  return <h1>Hello, {name}!</h1>;
}
Greeting.test.tsx
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:

terminal
βœ“ src/components/Greeting.test.tsx (1 test) 24ms
  βœ“ Greeting > greets the user by name

Test Files  1 passed (1)
     Tests  1 passed (1)
πŸ’‘ Why 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.
4

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:

Variant0 matches1 matchUse it for
getBy*πŸ’₯ throwsreturns elementElements that MUST be there right now
queryBy*returns nullreturns elementAsserting something is NOT rendered
findBy*retries ~1s, then rejectsresolves elementElements 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):

PriorityQueryFinds elements by…
1️⃣ BestgetByRoleAccessible role + name: getByRole("button", { name: /save/i })
2️⃣getByLabelTextThe <label> of a form field β€” how users find inputs
3️⃣getByPlaceholderTextPlaceholder text (only when there is no label β€” fix the label instead!)
4️⃣getByTextVisible text content β€” great for non-interactive elements
5️⃣getByDisplayValueThe current value of an input
6️⃣ Last resortgetByTestIddata-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)

screen.getByRole("button", { name: /sign in/i })
βœ… Matches the Sign in button β€” the #1 preferred query. Throws if 0 or 2+ match.
⚠️ The #1 query mistake: using 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*.
5

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) fires pointerdown, mousedown, focus, pointerup, mouseup, then click β€” exactly like a real browser
  • await user.type(input, "hello") presses each key one at a time, firing keydown β†’ input β†’ keyup per character
  • It respects reality: it refuses to click a disabled button or type into a readOnly input β€” 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

What the test would see right now
βœ…expect(screen.getByText('Count: 0')).toBeInTheDocument()The initial-render assertion β€” passes only before any clicks.
❌expect(screen.getByText('Count: 3')).toBeInTheDocument()Passes only after exactly 3 clicks β€” just like the test performs.
❌expect(screen.getByRole('button', { name: /increment/i })).toBeDisabled()The counter caps at 5 β€” the button disables, and the test can assert it.
βœ…expect(screen.getByRole('button', { name: /increment/i })).toBeEnabled()The opposite assertion β€” only one of these two can pass at a time.
⚠️ Every userEvent call returns a Promise. Forgetting 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.
6

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 with queryByText

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:

What the test would see right now
❌expect(screen.getByLabelText(/email/i)).toHaveValue('[email protected]')Passes once you type exactly [email protected] into the Email field.
⏳expect(screen.getByText('Email is required')).toBeInTheDocument()Pending until you submit β€” then passes only if Email was left empty.
βœ…expect(screen.queryByText(/at least 8 characters/i)).toBeNull()queryBy* asserts ABSENCE β€” fails the moment the password error renders.
❌expect(screen.getByText(/account created/i)).toBeInTheDocument()The happy path: valid email + 8-char password + submit.
βœ… Notice what the tests never touch: no 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.
7

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:

ToolWhat it doesWhen 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 throwMultiple assertions, or non-query checks (mock call counts)
waitForElementToBeRemoved(...)Resolves when the element disappearsAsserting 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:

(not mounted yet)
What the test would see right now
⏳expect(screen.getByText(/loading profile/i)).toBeInTheDocument()Synchronous assertion β€” only true during the loading window.
⏳await screen.findByText('Alice Santos')findBy* keeps retrying while loading β€” resolves the moment the name renders (or times out after 1s).
⏳expect(screen.queryByText(/loading profile/i)).toBeNull()After the data lands, the spinner must be GONE β€” assert absence with queryBy*.
πŸ’‘ findBy = getBy + waitFor. 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.
8

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:

1vi.fn() β€” mock a callback prop

The most common case: your component receives an onSave-style callback, and the test verifies it was called with the right arguments.

TodoItem.test.tsx
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");
  });
});
2vi.mock() β€” replace a whole module

When your component imports an API module directly, mock the module so the component-under-test never knows the difference:

OrderList.test.tsx
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();
  });
});
3Mocking global fetch (and when to graduate to MSW)
mock-fetch.test.ts
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
});
πŸ’‘ Production tip β€” MSW: stubbing 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.
⚠️ Mock the boundary, not the world. If a test mocks the component's own hooks or internal functions, you are testing the mock, not the component. Mock only things that cross a boundary β€” network, timers, browser APIs β€” and let everything inside React run for real.
9

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] }
What the test would see right now
βœ…expect(result.current.on).toBe(false) // initial statePasses only on a fresh renderHook, before any act().
❌act(() => result.current.toggle()); expect(result.current.on).toBe(true)Passes after exactly one toggle.
❌act(() => result.current.toggle()); expect(result.current.on).toBe(false)Toggle twice = back to false. Behavior, verified without a single DOM node.

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.

10

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:

WelcomeBanner.test.tsx
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:

src/test/test-utils.tsx
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";
WelcomeBanner.test.tsx (using test-utils)
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();
});
βœ… One wrapper to rule them all. When you add a new app-wide provider next quarter (a query client, an i18n provider), you update test-utils.tsx once and every existing test keeps working. This single file is the highest-leverage piece of test infrastructure you will write.
11

Coverage & What NOT to Test

Enable coverage to find the untested branches that matter β€” the error path nobody exercised, the empty-list state:

vitest.config.ts (coverage)
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.
⚠️ A word on snapshot tests. 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.
12

Common Mistakes

1getByTestId as the default query
⚠️ Test ids everywhere = accessibility debt hidden by tests. If 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.
mistake-testid.test.tsx
// ❌ 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 }));
2Missing await on userEvent / findBy
⚠️ The flaky-test factory. An un-awaited 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.
mistake-missing-await.test.tsx
// ❌ 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();
3Testing state instead of the DOM
⚠️ Users can't see your state. Asserting that a state variable equals true couples the test to today's implementation. Assert what renders β€” that survives the switch from useState to useReducer to a store.
mistake-testing-state.test.tsx
// ❌ 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();
4Brittle mega-snapshots
mistake-snapshot.test.tsx
// ❌ 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();
13

Best Practices Checklist + Reference

βœ…Query by role/label first β€” getByTestId is the last resort, not the default
βœ…userEvent over fireEvent, with userEvent.setup() and an await on every interaction
βœ…queryBy* for absence, findBy* for async β€” never a sleep, never a getBy for "not there"
βœ…Mock at the boundary (network, timers, browser APIs) β€” never a component's own internals
βœ…One custom render (renderWithProviders) that carries all app providers
βœ…Each test covers one behavior with a name that reads like a spec: "shows an error when the email is empty"
βœ…Install eslint-plugin-testing-library β€” it flags missing awaits and anti-pattern queries automatically

Query cheat sheet β€” the grid to keep beside your editor:

GoalQuery to useExample
Button / link / headinggetByRolegetByRole("button", { name: /save/i })
Form inputgetByLabelTextgetByLabelText(/email/i)
Static visible textgetByTextgetByText(/welcome back/i)
Element that appears laterawait findByTextawait findByText("Alice Santos")
Element that must NOT existqueryByText + toBeNullexpect(queryByText(/error/i)).toBeNull()
Several similar itemsgetAllByRolegetAllByRole("listitem") // assert .length

Matchers you will use daily (from vitest + jest-dom):

MatcherAsserts 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
14

Practice Project + What's Next

Cement all of it by test-driving a searchable contact list:

  • A ContactList that fetches contacts on mount β€” test the loading state with getByText, the loaded list with findAllByRole("listitem"), and the error state with a rejected mock
  • A search input (found via getByLabelText) β€” await user.type(...) and assert filtered results, plus queryByText for the ones filtered out
  • An "Add contact" form β€” validation errors on empty submit, success path, and a vi.fn() asserting onAdd got the right payload
  • A useContactSearch custom hook β€” test it directly with renderHook + act
  • Wrap it all with a renderWithProviders if 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.

πŸš€ Next Learning Topics:
  • Custom Hooks β€” extract more reusable logic, then point renderHook at 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.

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.