Testing and Interview Prep · Lesson 30 of 31
Testing React with Vitest and Testing Library
Tests let you change code without fear. For React, the standard toolkit is Vitest (a fast test runner that shares Vite's config) with React Testing Library, which renders components and lets you interact with them the way a user would – by finding buttons and labels, clicking and typing – instead of poking at internal state.
In this lesson
- Setting up Vitest and Testing Library
- Rendering and querying by role and label
- Simulating users with
user-event - Async UI, mocking requests and testing hooks
Setup
npm install -D vitest jsdom @testing-library/react @testing-library/user-event @testing-library/jest-dom
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom", // a simulated browser DOM
setupFiles: ["./src/test/setup.js"],
},
});
// src/test/setup.js
import "@testing-library/jest-dom/vitest"; // adds toBeInTheDocument, toHaveTextContent, …
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => cleanup()); // unmount after every test
Add "test": "vitest" to the scripts in package.json and run npm test. Test files are named *.test.jsx and usually sit next to the component.
Your first test
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect } from "vitest";
import { useState } from "react";
// the component under test (normally imported from ./Counter.jsx)
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
describe("Counter", () => {
it("starts at zero", () => {
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
});
it("increments when the button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: "Increment" }));
await user.click(screen.getByRole("button", { name: "Increment" }));
expect(screen.getByText("Count: 2")).toBeInTheDocument();
});
});
✓ Counter > starts at zero ✓ Counter > increments when the button is clicked Tests 2 passed (2)
The shape of every test
- Arrangerender(<Component />) with the props you need
- Actclick, type, select with user-event
- Assertexpect what the user should now see
Finding elements: query priority
| Query | Use for |
|---|---|
getByRole("button", { name: "Save" }) | first choice: buttons, links, headings, checkboxes, textboxes |
getByLabelText("Email") | form fields with a label |
getByPlaceholderText, getByText | when there's no role or label |
getByTestId("chart") | last resort (data-testid) |
Each comes in three forms: getBy… (throws if missing), queryBy… (returns null – use it to assert something is absent), and findBy… (returns a promise and waits – for things that appear later). Querying by role also checks that your UI is accessible: if a test can't find the button by its name, neither can a screen reader.
Testing a form
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { it, expect, vi } from "vitest";
import { useState } from "react";
function LoginForm({ onLogin }) {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
function handleSubmit(e) {
e.preventDefault();
if (!email.includes("@")) return setError("Enter a valid email");
onLogin(email);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input id="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<button>Log in</button>
{error && <p role="alert">{error}</p>}
</form>
);
}
it("shows an error for an invalid email", async () => {
const user = userEvent.setup();
const onLogin = vi.fn(); // a mock function
render(<LoginForm onLogin={onLogin} />);
await user.type(screen.getByLabelText("Email"), "asha");
await user.click(screen.getByRole("button", { name: "Log in" }));
expect(screen.getByRole("alert")).toHaveTextContent("Enter a valid email");
expect(onLogin).not.toHaveBeenCalled();
});
it("calls onLogin with the email", async () => {
const user = userEvent.setup();
const onLogin = vi.fn();
render(<LoginForm onLogin={onLogin} />);
await user.type(screen.getByLabelText("Email"), "asha@example.com");
await user.click(screen.getByRole("button", { name: "Log in" }));
expect(onLogin).toHaveBeenCalledWith("asha@example.com");
expect(screen.queryByRole("alert")).not.toBeInTheDocument(); // queryBy for absence
});
✓ shows an error for an invalid email ✓ calls onLogin with the email Tests 2 passed (2)
Async UI and mocking fetch
import { render, screen } from "@testing-library/react";
import { it, expect, vi, afterEach } from "vitest";
import { useEffect, useState } from "react";
function CourseList() {
const [courses, setCourses] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
fetch("/api/courses")
.then((res) => (res.ok ? res.json() : Promise.reject(new Error(`HTTP ${res.status}`))))
.then(setCourses)
.catch((err) => setError(err.message));
}, []);
if (error) return <p role="alert">Couldn't load courses: {error}</p>;
if (!courses) return <p>Loading…</p>;
return <ul>{courses.map((c) => <li key={c}>{c}</li>)}</ul>;
}
afterEach(() => vi.unstubAllGlobals());
it("renders courses from the API", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ["React.js", "Next.js"] }));
render(<CourseList />);
expect(screen.getByText("Loading…")).toBeInTheDocument();
expect(await screen.findAllByRole("listitem")).toHaveLength(2); // findBy waits for the update
expect(screen.getByText("Next.js")).toBeInTheDocument();
});
it("shows an error when the API fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500 }));
render(<CourseList />);
expect(await screen.findByRole("alert")).toHaveTextContent("HTTP 500");
});
✓ renders courses from the API ✓ shows an error when the API fails Tests 2 passed (2)
For bigger apps, Mock Service Worker (MSW) intercepts requests at the network level, so components use their real fetch calls and the same mocks work in tests, Storybook and the browser.
Testing a custom hook
import { renderHook, act } from "@testing-library/react";
import { it, expect } from "vitest";
import { useState } from "react";
function useCounter(initial = 0, max = Infinity) {
const [count, setCount] = useState(initial);
return { count, increment: () => setCount((c) => Math.min(max, c + 1)) };
}
it("never goes above max", () => {
const { result } = renderHook(() => useCounter(2, 3));
act(() => result.current.increment());
act(() => result.current.increment());
expect(result.current.count).toBe(3);
});
✓ never goes above max Tests 1 passed (1)
What a failing test looks like
import { render, screen } from "@testing-library/react";
import { it, expect } from "vitest";
function Price({ amount }) {
return <p>Total: ₹{amount * 1.18}</p>; // bug: no rounding
}
it("shows the price including 18% GST", () => {
render(<Price amount={499} />);
expect(screen.getByText("Total: ₹588.82")).toBeInTheDocument();
});
× shows the price including 18% GST → Unable to find an element with the text: Total: ₹588.82. This could be because the text is broken up by multiple elements. In this case, you can provide a function for your text matcher to make your matcher more flexible. Tests 1 failed (1)
The test caught a real floating-point bug: 499 * 1.18 isn't exactly 588.82, so the page shows a long decimal. The fix is (amount * 1.18).toFixed(2).
Good testing habits
- Test behaviour, not implementation: what the user sees and does, not state variable names.
- Prefer
getByRolewith a name; useuser-eventoverfireEvent. - Use
findBy…for anything async; avoid fixed sleeps. - Most value comes from integration-style component tests, plus a few end-to-end tests (Playwright) for critical flows like sign-up and checkout.
Practice
Try it yourself
Test a toggle: the panel text is hidden at first, visible after one click, hidden again after a second.
Show solution
import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { it, expect } from "vitest"; import { useState } from "react"; function Faq() { const [open, setOpen] = useState(false); return ( <section> <button onClick={() => setOpen(!open)}>Is it free?</button> {open && <p>Yes, every lesson is free.</p>} </section> ); } it("toggles the answer", async () => { const user = userEvent.setup(); render(<Faq />); const question = screen.getByRole("button", { name: "Is it free?" }); expect(screen.queryByText(/every lesson is free/)).not.toBeInTheDocument(); await user.click(question); expect(screen.getByText(/every lesson is free/)).toBeInTheDocument(); await user.click(question); expect(screen.queryByText(/every lesson is free/)).not.toBeInTheDocument(); });✓ toggles the answer Tests 1 passed (1)
Quick check
Which query should you reach for first?
getByRole with an accessible name.
How do you assert that something is not on screen?
expect(screen.queryByText(…)).not.toBeInTheDocument().
How do you wait for data that loads asynchronously?
Use a findBy… query, which returns a promise.
Summary
- Vitest + jsdom + React Testing Library test components in a simulated browser.
- Query by role and label, act with user-event, and wait with findBy.
- Mock network calls with vi.stubGlobal or MSW; test hooks with renderHook.
Finished this lesson?
Mark it complete to track your progress, then carry on with the next lesson.