Frontend code is tested at two levels: pure functions with ordinary unit tests in Vitest, and components through Testing Library the way a person uses them — find elements by role and label, click and type with user-event, and assert on what is visible on screen. The network is replaced in these tests, either with vi.stubGlobal for fetch or with MSW at the network level. A good test fails when behaviour breaks and stays quiet when you merely rewrite a component’s internals.
After this you will be able to
- Write a unit test for a pure function in Vitest and confirm it catches a deliberate bug
- Test a React component by behaviour: find elements by role and label, click and type with
user-event - Mock a network request with
vi.stubGlobalandvi.fnor with MSW and check the success and error scenarios - Tell a robust test from a brittle one and explain why
getByRolebeats querying by class
Know beforehand
Verified with
Vitest 5.0@testing-library/react 16.3@testing-library/user-event 14.6@testing-library/jest-dom 7.0MSW 3.0React 19.3Vite 8.3jsdom 30.1Node.js 24 LTS (24.18)
Checked against docs: October 11, 2026
Contents
What to test on the frontend and why
An automated test is code that runs your code and compares the result with what you expect. Its value comes down to one thing: it catches regressions — breakage of something that used to work — before users do. Its cost is also one thing: tests need maintenance. So the question is not “how do we cover everything” but “which tests give confidence at a reasonable price”.
Unit test
- What it checks
- A pure function: cart total, phone formatting, validation
- Tool in this lesson
- Vitest
Component test
- What it checks
- A component in the DOM: what is visible, what happens after a click or typing, what goes to the network
- Tool in this lesson
- Vitest + jsdom + Testing Library + user-event
End-to-end
- What it checks
- A scenario in a real browser against the built application
- Tool in this lesson
- Playwright — a separate lesson
Testing Library’s documentation states its guiding principle this way: “The more your tests resemble the way your software is used, the more confidence they can give you.” A user knows nothing about useState, CSS classes or prop names — they see a “Phone” field, a “Send request” button and an error message. A test that relies on the same things survives refactoring: you can swap useState for a reducer or rework the markup, and the test stays green as long as behaviour hasn’t changed.
Setup: Vitest, jsdom and Testing Library
Vitest is a test runner built on Vite: it reads the same vite.config.ts, so aliases, plugins and JSX work in tests without extra configuration. According to its docs, Vitest 5 requires Vite 6.4 or newer and Node.js 22.12 or newer. React Testing Library is installed together with @testing-library/dom, its required peer dependency. jsdom is a JavaScript implementation of the DOM: by default Vitest runs tests in a Node.js environment with no document, so for components the environment is switched to jsdom.
npm create vite@latest app -- --template react-ts
cd app
npm install
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom msw@3import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
unstubGlobals: true,
},
});import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
afterEach(() => {
cleanup();
});environment: "jsdom"gives testsdocument,windowand DOM events.setupFilesruns before every test file. Importing@testing-library/jest-dom/vitestadds matchers such astoHaveTextContent,toBeDisabledandtoHaveAccessibleDescriptiontoexpect.cleanup()unmounts rendered components. Testing Library’s docs say it runs automatically only if the test runner injects a globalafterEach. Vitest globals are off by default, so we wire cleanup ourselves. We checked: without it, two of this lesson’s form tests fail because forms from earlier tests stay on the page andgetByRolefinds several buttons.unstubGlobals: truemakes Vitest callvi.unstubAllGlobals()before every test, so a replacedfetchdoesn’t leak into neighbouring tests (the option is off by default).
Vitest finds test files by name: it must contain .test. or .spec.. npx vitest runs tests in watch mode and reruns them on save; npx vitest run does a single run, as in CI.
Testing a pure function
Let’s start with the cheapest and most reliable kind of test. cartTotal computes a cart total in tenge with a percentage discount. It touches neither the DOM nor the network: the same input always gives the same result. Functions like this are worth pulling out of components precisely because they are easy to test.
export type CartItem = { id: string; price: number; qty: number };
export function cartTotal(items: CartItem[], discountPercent = 0): number {
if (discountPercent < 0 || discountPercent > 100) {
throw new RangeError("discountPercent must be between 0 and 100");
}
const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
return Math.round(subtotal * (1 - discountPercent / 100));
}import { describe, expect, it } from "vitest";
import { cartTotal } from "./cart";
describe("cartTotal", () => {
it("returns 0 for an empty cart", () => {
expect(cartTotal([])).toBe(0);
});
it("multiplies price by quantity", () => {
const items = [
{ id: "latte", price: 1800, qty: 2 },
{ id: "croissant", price: 900, qty: 1 },
];
expect(cartTotal(items)).toBe(4500);
});
it("applies a percentage discount and rounds to whole tenge", () => {
expect(cartTotal([{ id: "latte", price: 1800, qty: 2 }], 10)).toBe(3240);
expect(cartTotal([{ id: "tea", price: 999, qty: 1 }], 15)).toBe(849);
});
it("rejects a discount outside 0–100", () => {
expect(() => cartTotal([], 120)).toThrow(RangeError);
});
});Each it checks one case of behaviour and is named so the report tells you what broke: an empty cart, multiplying by quantity, a discount with rounding, an invalid discount. Edge values — an empty array, 999 × 0.85 = 849.15 → 849, a 120% discount — find more bugs than ten “ordinary” carts.
$ npx vitest run src/cart.test.ts --reporter=verbose
RUN v5.0.3 …/app
✓ src/cart.test.ts > cartTotal > returns 0 for an empty cart 1ms
✓ src/cart.test.ts > cartTotal > multiplies price by quantity 0ms
✓ src/cart.test.ts > cartTotal > applies a percentage discount and rounds to whole tenge 0ms
✓ src/cart.test.ts > cartTotal > rejects a discount outside 0–100 0ms
Test Files 1 passed (1)
Tests 4 passed (4)A test that has never failed proves nothing. Let’s break the function on purpose and “forget” to multiply the price by the quantity:
--- a/src/cart.ts
+++ b/src/cart.ts
@@ -7,3 +7,3 @@
}
- const subtotal = items.reduce((sum, item) => sum + item.price * item.qty, 0);
+ const subtotal = items.reduce((sum, item) => sum + item.price, 0);
return Math.round(subtotal * (1 - discountPercent / 100));$ npx vitest run src/cart.test.ts; echo "exit=$?"
RUN v5.0.3 …/app
❯ src/cart.test.ts (4 tests | 2 failed) 9ms
❯ cartTotal (4)
× multiplies price by quantity 5ms
× applies a percentage discount and rounds to whole tenge 1ms
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
FAIL src/cart.test.ts > cartTotal > multiplies price by quantity
AssertionError: expected 2700 to be 4500 // Object.is equality
- Expected
+ Received
- 4500
+ 2700
❯ src/cart.test.ts:14:30
12| { id: "croissant", price: 900, qty: 1 },
13| ];
14| expect(cartTotal(items)).toBe(4500);
| ^
15| });
16|
…
⎯⎯⎯⎯⎯⎯⎯ Failed Tests 2 ⎯⎯⎯⎯⎯⎯⎯
Test Files 1 failed (1)
Tests 2 failed | 2 passed (4)
exit=1The report shows the test name, the expected and received values (4500 versus 2700) and the line with the assertion. The empty-cart and invalid-discount tests stayed green — they don’t catch this bug, and that’s fine: each test owns its own case. After reverting the change, all four tests pass again.
Testing a component by behaviour: roles and user-event
Now the lead form. It validates the phone number, sends the data with sendLead, disables the button while sending and shows a confirmation or an error. Note the markup: labels are tied to fields with htmlFor, the field error is linked with aria-describedby, and messages have the alert and status roles. This helps screen reader users and tests alike: Testing Library finds elements the same way assistive technology does.
export type Lead = { name: string; phone: string };
export async function sendLead(lead: Lead): Promise<{ id: string }> {
const res = await fetch("/api/leads", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(lead),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
}import { useState, type FormEvent } from "react";
import { sendLead } from "./api";
type Status = "idle" | "sending" | "sent" | "error";
const PHONE_RE = /^\+?\d{10,15}$/;
export function LeadForm() {
const [name, setName] = useState("");
const [phone, setPhone] = useState("");
const [phoneError, setPhoneError] = useState("");
const [status, setStatus] = useState<Status>("idle");
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!PHONE_RE.test(phone.replace(/[\s()-]/g, ""))) {
setPhoneError("Enter a phone number, e.g. +7 701 123 45 67");
return;
}
setPhoneError("");
setStatus("sending");
try {
await sendLead({ name, phone });
setStatus("sent");
} catch {
setStatus("error");
}
}
if (status === "sent") {
return <p role="status">Thanks! We will call you back.</p>;
}
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor="lead-name">Name</label>
<input id="lead-name" value={name} onChange={(e) => setName(e.target.value)} />
<label htmlFor="lead-phone">Phone</label>
<input
id="lead-phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
aria-invalid={phoneError ? true : undefined}
aria-describedby={phoneError ? "lead-phone-error" : undefined}
/>
{phoneError && (
<p id="lead-phone-error" role="alert">
{phoneError}
</p>
)}
{status === "error" && <p role="alert">Could not send. Please try again.</p>}
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send request"}
</button>
</form>
);
}Testing Library’s documentation sets a priority for queries. At the top are queries accessible to everyone: getByRole (“your top preference for just about everything”), getByLabelText, getByPlaceholderText, getByText, getByDisplayValue. Next come the semantic getByAltText and getByTitle. Last is getByTestId: no user can see or hear a data-testid attribute, so it is reserved for cases where neither role nor text works.
getBy…
- No element
- Throws an error
- When to use
- The element must already be on the page
queryBy…
- No element
- Returns
null - When to use
- Assert that an element is absent:
expect(screen.queryByRole("alert")).toBeNull()
findBy…
- No element
- The promise rejects after waiting (1000 ms by default)
- When to use
- The element appears later — after a server response or a timer
For actions we use user-event, not fireEvent. fireEvent dispatches a single DOM event, while user-event reproduces the whole interaction: a click is hover, mouse down, mouse up and focus; typing is a sequence of keyboard events per character. It also checks whether an element can be interacted with: it won’t click a hidden element or type into a disabled field. The instance is created with userEvent.setup() before rendering, and every method is async, so it must be awaited.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { LeadForm } from "./LeadForm";
function setup() {
const user = userEvent.setup();
render(<LeadForm />);
return {
user,
name: screen.getByRole("textbox", { name: "Name" }),
phone: screen.getByRole("textbox", { name: "Phone" }),
submit: screen.getByRole("button", { name: "Send request" }),
};
}
describe("LeadForm", () => {
it("does not send an invalid phone and explains why", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const { user, phone, submit } = setup();
await user.type(phone, "123");
await user.click(submit);
expect(screen.getByRole("alert")).toHaveTextContent("Enter a phone number");
expect(phone).toHaveAttribute("aria-invalid", "true");
expect(phone).toHaveAccessibleDescription(/Enter a phone number/);
expect(fetchMock).not.toHaveBeenCalled();
});
it("sends the lead and shows a confirmation", async () => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({ id: "lead-1" }, { status: 201 }));
vi.stubGlobal("fetch", fetchMock);
const { user, name, phone, submit } = setup();
await user.type(name, "Aigerim");
await user.type(phone, "+7 701 123 45 67");
await user.click(submit);
expect(await screen.findByRole("status")).toHaveTextContent("Thanks!");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe("/api/leads");
expect(JSON.parse(init.body)).toEqual({ name: "Aigerim", phone: "+7 701 123 45 67" });
});
it("shows an error and lets the user retry when the server fails", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 500 })));
const { user, phone, submit } = setup();
await user.type(phone, "+77011234567");
await user.click(submit);
expect(await screen.findByRole("alert")).toHaveTextContent("Could not send");
expect(screen.getByRole("button", { name: "Send request" })).toBeEnabled();
});
});setup()is a plain helper: it createsuser, renders the form and finds fields by role and accessible name. It is called inside each test, as the user-event docs advise.- A
type="tel"field has thetextboxrole, so both fields are found the same way:getByRole("textbox", { name: "Phone" }). If someone unlinks the label from the field, the test fails — and that is the right failure: a screen reader would stop announcing it too. findByRole("status")waits for the async submission to finish and React to render the confirmation. No manualsetTimeoutorsleepis needed in tests.- We assert on what the user sees (text, button state,
aria-invalid) and on what leaves the app (the request URL and body). The test never touches the component’s internalstatus.
Mocking the network: vi.fn and MSW
A test must not call the real API: the server may be down, and a lead from a test must not end up in the CRM. So the network is replaced with a mock — a controllable stand-in. There are two options, and they complement each other.
`vi.stubGlobal("fetch", vi.fn())`
- Replaces the global
fetchwith a mock function that records calls and arguments - The response is set right in the test:
mockResolvedValue(Response.json(…)) - Nothing to install — it all ships with Vitest
- The test knows the code uses
fetchspecifically; switching to another HTTP client breaks the mocks
MSW (Mock Service Worker)
- Intercepts requests at the network level: in Node.js through its own interceptor rather than by replacing
fetch - Neither the app code nor the test knows about the mock: works with
fetch, axios and other clients - Handlers can be reused in development and in Storybook
- An extra dependency and slightly more setup
The first option is already in LeadForm.test.tsx: vi.fn() creates a mock, vi.stubGlobal puts it in place of fetch, and the unstubGlobals option restores the original before the next test. Vitest’s documentation on mocking requests explicitly recommends MSW. The same scenario with MSW looks like this:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw/http";
import { setupServer } from "msw/node";
import { afterAll, afterEach, beforeAll, expect, it } from "vitest";
import { LeadForm } from "./LeadForm";
const received: unknown[] = [];
const server = setupServer(
http.post("/api/leads", async ({ request }) => {
received.push(await request.json());
return HttpResponse.json({ id: "lead-1" }, { status: 201 });
}),
);
beforeAll(() => server.listen({ onUnhandledFrame: "error" }));
afterEach(() => {
server.resetHandlers();
received.length = 0;
});
afterAll(() => server.close());
it("sends the lead over the network", async () => {
const user = userEvent.setup();
render(<LeadForm />);
await user.type(screen.getByRole("textbox", { name: "Phone" }), "+77011234567");
await user.click(screen.getByRole("button", { name: "Send request" }));
expect(await screen.findByRole("status")).toHaveTextContent("Thanks!");
expect(received).toEqual([{ name: "", phone: "+77011234567" }]);
});
it("shows an error when the API is down", async () => {
server.use(http.post("/api/leads", () => new HttpResponse(null, { status: 503 })));
const user = userEvent.setup();
render(<LeadForm />);
await user.type(screen.getByRole("textbox", { name: "Phone" }), "+77011234567");
await user.click(screen.getByRole("button", { name: "Send request" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not send");
});server.listen()enables interception before the file’s tests,resetHandlers()removes handlers added withserver.use()after each test, andclose()turns interception off.server.use()overrides the response for a single test — this is how the “API is down” scenario is checked without a separate mock server.- The handler receives a real
Request: the body is read withrequest.json(), so the test sees exactly what would have gone over the network.
$ npx vitest run src/LeadForm.test.tsx src/LeadForm.msw.test.tsx --reporter=verbose
RUN v5.0.3 …/app
✓ src/LeadForm.test.tsx > LeadForm > does not send an invalid phone and explains why 252ms
✓ src/LeadForm.msw.test.tsx > sends the lead over the network 411ms
✓ src/LeadForm.msw.test.tsx > shows an error when the API is down 292ms
✓ src/LeadForm.test.tsx > LeadForm > sends the lead and shows a confirmation 500ms
✓ src/LeadForm.test.tsx > LeadForm > shows an error and lets the user retry when the server fails 344ms
Test Files 2 passed (2)
Tests 5 passed (5)What not to test and how to avoid brittle tests
A brittle test fails when behaviour hasn’t changed: a class was renamed, a component was split in two, the state library was swapped. After a few such failures the team stops trusting the tests and starts “fixing” them without looking. The cause is almost always the same — the test checks implementation details instead of the result.
Brittle
- Finding elements by classes and structure:
container.querySelector(".form > div:nth-child(2) input") - Checking internal state, child component props, render counts
- Mocking your own modules for “isolation”: the test checks the mock, not the code
- A snapshot of the whole markup that gets updated with a command without reading the diff
- Waiting a fixed amount of time instead of waiting for an element
Robust
- Finding by role and accessible name:
getByRole("button", { name: "Send request" }) - Checking the visible result: text, button state, error message
- Mocking only the boundary with the outside world: network, time, browser storage
- Targeted assertions on what matters to the user
- Waiting with
findBy…andwaitFor
What usually isn’t worth testing on its own: React and the browser themselves (that onClick fires on click), third-party libraries, visual appearance (colours and spacing are better caught by visual tests or review), trivial components without logic. In our experience, the most valuable tests cover rules about money and data (cart, discounts, phone normalisation), forms with validation and error branches — exactly the paths that are least often checked by hand.
- The test has been seen red at least once: you broke the code and made sure the test fails
- Elements are found by role, label or text;
data-testidonly when nothing else works - Actions go through
user-eventwithawait; async results are awaited withfindBy… - The network is mocked; a request without a handler fails the test instead of hitting the real API
- Mocks and stubs are reset between tests (
unstubGlobals,resetHandlers,cleanup) - The test name describes behaviour: the report tells you what broke
- The test doesn’t change when you rewrite a component’s internals without changing behaviour
Exercises
The next step is end-to-end tests in a real browser that check the application as a whole, and accessibility in code: the same roles and labels that this lesson’s tests rely on.
How this was verified5 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.