Фронтенд тестируют на двух уровнях: чистые функции — обычными unit-тестами в Vitest, компоненты — через Testing Library так, как ими пользуется человек: находят элементы по ролям и подписям, кликают и печатают через user-event и проверяют то, что видно на экране. Сеть в таких тестах подменяют — через vi.stubGlobal для fetch или через MSW на уровне сетевых запросов. Хороший тест падает, когда сломалось поведение, и молчит, когда вы просто переписали код внутри компонента.
После материала вы сможете
- написать unit-тест чистой функции в Vitest и убедиться, что он ловит намеренную ошибку
- протестировать React-компонент через поведение: найти элементы по роли и подписи, кликнуть и ввести текст через
user-event - подменить сетевой запрос через
vi.stubGlobalиvi.fnили через MSW и проверить сценарии успеха и ошибки - отличить устойчивый тест от хрупкого и объяснить, почему
getByRoleлучше поиска по классам
Что знать заранее
Проверено на
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)
Сверено с документацией: 11 октября 2026 г.
Содержание
Что и зачем тестировать на фронтенде
Автоматический тест — это код, который запускает ваш код и сравнивает результат с ожидаемым. Его ценность в одном: он ловит регрессию — поломку того, что уже работало, — раньше пользователя. Цена тоже одна: тесты надо поддерживать. Поэтому вопрос не «как покрыть всё», а «какие тесты дают уверенность за разумную цену».
Unit-тест
- Что проверяет
- Чистую функцию: расчёт корзины, форматирование телефона, валидацию
- Инструмент в этом уроке
- Vitest
Тест компонента
- Что проверяет
- Компонент в DOM: что видно, что происходит после клика и ввода, что ушло в сеть
- Инструмент в этом уроке
- Vitest + jsdom + Testing Library + user-event
End-to-end
- Что проверяет
- Сценарий в настоящем браузере на собранном приложении
- Инструмент в этом уроке
- Playwright — отдельный урок
Главный принцип Testing Library сформулирован в её документации так: «чем больше ваши тесты похожи на то, как используется ваше ПО, тем больше уверенности они дают». Пользователь не знает про useState, классы CSS и имена пропсов — он видит поле «Phone», кнопку «Send request» и сообщение об ошибке. Тест, который опирается на то же самое, переживает рефакторинг: можно заменить useState на редьюсер или переверстать форму, и тест останется зелёным, пока поведение не изменилось.
Установка: Vitest, jsdom и Testing Library
Vitest — тест-раннер на основе Vite: он читает тот же vite.config.ts, поэтому алиасы, плагины и JSX работают в тестах без отдельной настройки. По документации Vitest 5 требует Vite не ниже 6.4 и Node.js не ниже 22.12. Testing Library для React ставится вместе с @testing-library/dom — это обязательная парная зависимость. jsdom — реализация DOM на JavaScript: по умолчанию Vitest запускает тесты в окружении Node.js, где нет document, и для компонентов окружение переключают на 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"— в тестах появляютсяdocument,windowи DOM-события.setupFilesвыполняется перед каждым тестовым файлом. Импорт@testing-library/jest-dom/vitestдобавляет кexpectматчеры вродеtoHaveTextContent,toBeDisabled,toHaveAccessibleDescription.cleanup()размонтирует отрендеренные компоненты. Документация Testing Library говорит, что он вызывается автоматически, только если тест-раннер объявляет глобальныйafterEach. В Vitest глобальные функции по умолчанию выключены, поэтому очистку подключаем сами. Мы проверили: без неё два теста формы из этого урока падают, потому что на странице остаются формы из предыдущих тестов иgetByRoleнаходит несколько кнопок.unstubGlobals: true— Vitest сам вызываетvi.unstubAllGlobals()перед каждым тестом, и подменённыйfetchне протекает в соседние тесты (по умолчанию опция выключена).
Тестовые файлы Vitest находит по имени: в нём должно быть .test. или .spec.. Команда npx vitest запускает тесты в режиме наблюдения и перезапускает их при сохранении файла, npx vitest run — один прогон, как в CI.
Тест чистой функции
Начнём с самого дешёвого и самого надёжного вида теста. Функция cartTotal считает сумму корзины в тенге со скидкой в процентах. Она не трогает DOM и сеть: одни и те же входные данные — один и тот же результат. Такие функции стоит выносить из компонентов именно потому, что их легко тестировать.
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);
});
});Каждый it проверяет один случай поведения и называется так, чтобы по отчёту было понятно, что сломалось: пустая корзина, умножение на количество, скидка с округлением, некорректная скидка. Граничные значения — пустой массив, 999 × 0,85 = 849,15 → 849, скидка 120% — находят больше ошибок, чем десять «обычных» корзин.
$ 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/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=1Отчёт показывает имя теста, ожидаемое и полученное значение (4500 против 2700) и строку с проверкой. Тест пустой корзины и тест некорректной скидки остались зелёными — они эту ошибку не ловят, и это нормально: каждый тест отвечает за свой случай. После отката правки все четыре теста снова проходят.
Компонент через поведение: роли и user-event
Теперь форма заявки. Она проверяет телефон, отправляет данные функцией sendLead, на время отправки блокирует кнопку и показывает подтверждение или ошибку. Обратите внимание на разметку: подписи связаны с полями через htmlFor, ошибка поля связана через aria-describedby, сообщения имеют роли alert и status. Это нужно и людям со скринридером, и тестам: Testing Library ищет элементы так же, как их находит вспомогательная технология.
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 задаёт приоритет запросов. Сверху — запросы, доступные всем: getByRole («ваш главный выбор почти для всего»), getByLabelText, getByPlaceholderText, getByText, getByDisplayValue. Ниже — семантические getByAltText и getByTitle. В самом конце — getByTestId: атрибут data-testid не видит и не слышит ни один пользователь, поэтому его оставляют для случаев, когда ни роль, ни текст не подходят.
getBy…
- Нет элемента
- Бросает ошибку
- Когда использовать
- Элемент уже должен быть на странице
queryBy…
- Нет элемента
- Возвращает
null - Когда использовать
- Проверить, что элемента нет:
expect(screen.queryByRole("alert")).toBeNull()
findBy…
- Нет элемента
- Промис отклоняется после ожидания (по умолчанию 1000 мс)
- Когда использовать
- Элемент появится позже — после ответа сервера или таймера
Для действий используем user-event, а не fireEvent. fireEvent отправляет одно DOM-событие, а user-event воспроизводит взаимодействие целиком: клик — это наведение, нажатие и отпускание кнопки мыши, фокус; ввод — посимвольные события клавиатуры. Заодно он проверяет, можно ли с элементом взаимодействовать: не кликнет по скрытому элементу и не напечатает в заблокированное поле. Экземпляр создают через userEvent.setup() до рендера, а все методы асинхронные — их нужно ждать через await.
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()— обычная функция-помощник: создаётuser, рендерит форму и находит поля по роли и доступному имени. Вызывается внутри каждого теста, как советует документация user-event.- Поле
type="tel"имеет рольtextbox, поэтому оба поля ищутся одинаково:getByRole("textbox", { name: "Phone" }). Если подпись отвяжут от поля, тест упадёт — и это правильная поломка: скринридер тоже перестанет её читать. findByRole("status")ждёт, пока завершится асинхронная отправка и React отрисует подтверждение. РучныеsetTimeoutиsleepв тестах не нужны.- Проверяем то, что видно пользователю (текст, доступность кнопки,
aria-invalid), и то, что ушло наружу (адрес и тело запроса). Внутреннийstatusкомпонента тест не трогает.
Моки сети: vi.fn и MSW
В тесте нельзя ходить в настоящий API: сервер может быть недоступен, а заявка из теста не должна попасть в CRM. Сеть подменяют — делают мок, то есть управляемую заглушку. Вариантов два, и они дополняют друг друга.
`vi.stubGlobal("fetch", vi.fn())`
- Подменяет глобальный
fetchфункцией-моком, которая запоминает вызовы и аргументы - Ответ задаётся прямо в тесте:
mockResolvedValue(Response.json(…)) - Ничего не нужно ставить — всё есть в Vitest
- Тест знает, что код использует именно
fetch; переход на другой HTTP-клиент сломает моки
MSW (Mock Service Worker)
- Перехватывает запросы на сетевом уровне: в Node.js — через свой перехватчик, а не подмену
fetch - Код приложения и тест ничего не знают про мок: работает с
fetch, axios и другими клиентами - Обработчики можно переиспользовать в разработке и в Storybook
- Отдельная зависимость и чуть больше настройки
Первый вариант уже есть в LeadForm.test.tsx: vi.fn() создаёт мок, vi.stubGlobal ставит его вместо fetch, а опция unstubGlobals возвращает оригинал перед следующим тестом. Документация Vitest для мокинга запросов прямо рекомендует MSW. Тот же сценарий на MSW выглядит так:
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()включает перехват перед тестами файла,resetHandlers()после каждого теста убирает обработчики, добавленные черезserver.use(),close()выключает перехват.server.use()на время одного теста подменяет ответ — так проверяется сценарий «API лежит» без отдельного мок-сервера.- Обработчик получает настоящий
Request: тело читается черезrequest.json(), так что тест видит именно то, что ушло бы в сеть.
$ 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)Что не тестировать и как не делать хрупкие тесты
Хрупкий тест падает, когда поведение не изменилось: переименовали класс, разбили компонент на два, поменяли библиотеку состояния. После нескольких таких падений команда перестаёт доверять тестам и начинает их «чинить» не глядя. Причина почти всегда одна — тест проверяет детали реализации, а не результат.
Хрупко
- Искать элементы по классам и структуре:
container.querySelector(".form > div:nth-child(2) input") - Проверять внутреннее состояние, пропсы дочерних компонентов, число рендеров
- Мокать собственные модули ради «изоляции»: тест проверяет мок, а не код
- Снапшот всей разметки, который обновляют командой, не читая разницу
- Ждать фиксированное время вместо появления элемента
Устойчиво
- Искать по роли и доступному имени:
getByRole("button", { name: "Send request" }) - Проверять видимый результат: текст, доступность кнопки, сообщение об ошибке
- Мокать только границу с внешним миром: сеть, время, хранилище браузера
- Точечные проверки того, что важно пользователю
- Ждать через
findBy…иwaitFor
Что обычно не стоит тестировать отдельно: сами React и браузер (что onClick вызывается по клику), сторонние библиотеки, внешний вид (цвета и отступы надёжнее ловят визуальные тесты или ревью), тривиальные компоненты без логики. По нашему опыту, больше всего пользы приносят тесты на правила денег и данных (корзина, скидки, нормализация телефона), на формы с валидацией и на ветки ошибок — именно их реже всего проверяют руками.
- Тест хотя бы раз увидели красным: сломали код и убедились, что тест падает
- Элементы находятся по роли, подписи или тексту;
data-testid— только когда иначе нельзя - Действия идут через
user-eventсawait, асинхронный результат ждут черезfindBy… - Сеть подменена; запрос без обработчика роняет тест, а не уходит в настоящий API
- Моки и подмены сбрасываются между тестами (
unstubGlobals,resetHandlers,cleanup) - Название теста описывает поведение: по отчёту понятно, что сломалось
- Тест не меняется, если переписать компонент изнутри без изменения поведения
Упражнения
Следующий шаг — сквозные тесты в настоящем браузере, которые проверяют приложение целиком, и доступность в коде: те же роли и подписи, на которые опираются тесты из этого урока.
Как проверено5 · пройденосверено 11 октября 2026 г.
- запущено, работает
vite.config.ts, src/test/setup.ts, cart.ts, cart.test.ts, api.ts, LeadForm.tsx, LeadForm.test.tsx, LeadForm.msw.test.tsx
временный Vite-проект (npm create vite@latest --template react-ts): tsc -b без ошибок, vitest run — 17/17 тестов, exit 0; Vitest 5.0.3, React 19.3.0, @testing-library/react 16.3.3, user-event 14.6.7, jest-dom 7.0.1, MSW 3.0.3, jsdom 30.1.2, Vite 8.3.4, TypeScript 6.0.3, Node.js 24.18.0
- запущено, работает
намеренная ошибка в cartTotal (без * item.qty)
vitest run src/cart.test.ts: 2 failed | 2 passed, exit 1 — вывод в статье реальный, сокращён; после отката 4/4
- запущено, работает
без cleanup в setup.ts
контрольный прогон: 2 теста LeadForm падают (несколько элементов) — подтверждает утверждение статьи
- запущено, работает
MSW onUnhandledFrame: 'error'
fetch на адрес без обработчика отклоняется; вариант onUnhandledRequest отклонён tsc (TS2353) с MSW 3.0.3
- запущено, работает
решения упражнений: exercise-modify.test.tsx, phone.ts + phone.test.ts
в том же проекте: 1 + 7 тестов проходят; ответ упражнения 1 проверен прогоном без Math.round: 1 failed (expected 849.15 to be 849) | 3 passed
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.