How to buildOctober 11, 202610 min read
ReactStep 7 of 8

Frontend testing: Vitest and Testing Library

In short

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.

TutorialLevel: IntermediateFrontend TestingPractice: ~1.5–3 h

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.stubGlobal and vi.fn or with MSW and check the success and error scenarios
  • Tell a robust test from a brittle one and explain why getByRole beats querying by class

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
  1. 01What to test on the frontend and why
  2. 02Setup: Vitest, jsdom and Testing Library
  3. 03Testing a pure function
  4. 04Testing a component by behaviour: roles and user-event
  5. 05Mocking the network: vi.fn and MSW
  6. 06What not to test and how to avoid brittle tests
  7. 07Exercises

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.

bash
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@3
A new React + TypeScript project and the test dependencies
vite.config.ts
import 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,
  },
});
defineConfig from vitest/config adds types for the test block
src/test/setup.ts
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

afterEach(() => {
  cleanup();
});
Setup file: jest-dom matchers and DOM cleanup after every test
  • environment: "jsdom" gives tests document, window and DOM events.
  • setupFiles runs before every test file. Importing @testing-library/jest-dom/vitest adds matchers such as toHaveTextContent, toBeDisabled and toHaveAccessibleDescription to expect.
  • cleanup() unmounts rendered components. Testing Library’s docs say it runs automatically only if the test runner injects a global afterEach. 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 and getByRole finds several buttons.
  • unstubGlobals: true makes Vitest call vi.unstubAllGlobals() before every test, so a replaced fetch doesn’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.

src/cart.ts
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));
}
Cart total: price × quantity, discount, rounding to whole tenge
src/cart.test.ts
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);
  });
});
describe groups tests, it describes one case, expect checks the result

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.

terminal
$ 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)
Real output: 4 tests passed

A test that has never failed proves nothing. Let’s break the function on purpose and “forget” to multiply the price by the quantity:

src/cart.ts
--- 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));
A deliberate bug to check the tests
terminal
$ 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=1
Real output (trimmed at …): two tests failed, exit code 1 — CI would stop the build

The 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.

src/api.ts
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();
}
Sending a lead: a non-2xx response becomes an error
src/LeadForm.tsx
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>
  );
}
A lead form with phone validation and three states

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.

src/LeadForm.test.tsx
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();
  });
});
Three form scenarios: input error, success, server failure
  • setup() is a plain helper: it creates user, 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 the textbox role, 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 manual setTimeout or sleep is 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 internal status.

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 fetch with 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 fetch specifically; 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:

src/LeadForm.msw.test.tsx
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");
});
MSW: a POST /api/leads handler, reset between tests and a 503 error scenario
  • server.listen() enables interception before the file’s tests, resetHandlers() removes handlers added with server.use() after each test, and close() 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 with request.json(), so the test sees exactly what would have gone over the network.
terminal
$ 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)
Real output: five form tests — three with vi.fn and two with MSW

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… and waitFor

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-testid only when nothing else works
  • Actions go through user-event with await; async results are awaited with findBy…
  • 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.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.