Руководство по Vitest: от нуля до уверенности в продакшене
Юнит-тесты, интеграционные тесты, тестирование компонентов с Testing Library, стратегии моков, конфигурация покрытия и философия тестирования, которая реально улучшает софт.
Большинство команд пишут тесты примерно так же, как большинство людей занимаются спортом: знают, что надо, чувствуют вину, когда не делают, а когда наконец берутся — перегружают себя в первый же день и бросают к пятнице. В итоге в кодовой базе остаётся горстка поверхностных snapshot-тестов, которые ломаются при каждом изменении CSS-класса, пара интеграционных тестов, которым никто не доверяет, и бейджик покрытия в README, который врёт.
Я побывал по обе стороны. Я выпускал проекты без единого теста и потел при каждом деплое. Я также работал в командах, которые гнались за 100% покрытием и тратили больше времени на поддержку тестов, чем на написание фич. Ни то ни другое не работает. Работает написание правильных тестов, в правильных местах, с правильными инструментами.
Vitest изменил моё отношение к тестированию в JavaScript. Не потому, что он изобрёл новые концепции — фундаментальные принципы не менялись с тех пор, как Кент Бек писал о них десятилетия назад. А потому, что он убрал достаточно трения, чтобы написание тестов перестало казаться рутиной и стало частью цикла разработки. Когда ваш тестовый раннер работает так же быстро, как дев-сервер, и использует ту же конфигурацию — отговорки испаряются.
Этот пост — всё, что я знаю о тестировании с Vitest, от начальной настройки до философии, которая делает всё это стоящим.
Почему Vitest, а не Jest#
Если вы использовали Jest, вы уже знаете большую часть API Vitest. Это сделано намеренно — Vitest совместим с Jest на уровне API. describe, it, expect, beforeEach, vi.fn() — всё работает. Так зачем переходить?
Нативная поддержка ESM#
Jest был создан для CommonJS. Он может работать с ESM, но это требует настройки, экспериментальных флагов и периодических молитв. Если вы используете синтаксис import/export (а это всё современное), вы наверняка боролись с конвейером трансформации Jest.
Vitest работает на Vite. Vite нативно понимает ESM. Никакого этапа трансформации для вашего исходного кода — всё просто работает. Это важнее, чем кажется. Половина багов Jest, которые я отлаживал за эти годы, сводились к разрешению модулей: SyntaxError: Cannot use import statement outside a module или моки не работают, потому что модуль уже закеширован в другом формате.
Та же конфигурация, что и у дев-сервера#
Если ваш проект использует Vite (а если вы создаёте React, Vue или Svelte приложение в 2026 году, скорее всего так и есть), Vitest автоматически читает ваш vite.config.ts. Ваши алиасы путей, плагины и переменные окружения работают в тестах без дополнительной конфигурации. С Jest вы поддерживаете параллельную конфигурацию, которая должна оставаться синхронизированной с настройкой бандлера. Каждый раз, когда вы добавляете алиас пути в vite.config.ts, нужно не забыть добавить соответствующий moduleNameMapper в jest.config.ts. Мелочь, но мелочи накапливаются.
Скорость#
Vitest быстрый. Ощутимо быстрый. Не «экономит две секунды» быстрый, а «меняет ваш рабочий процесс» быстрый. Он использует граф модулей Vite, чтобы понять, какие тесты затронуты изменением файла, и запускает только их. Его watch-режим использует ту же инфраструктуру HMR, которая делает дев-сервер Vite мгновенным.
На проекте с 400+ тестами переход с Jest на Vitest сократил наш цикл обратной связи в watch-режиме с ~4 секунд до менее чем 500мс. Это разница между «подожду, пока тест пройдёт» и «гляну в терминал, пока пальцы ещё на клавиатуре».
Встроенный бенчмаркинг#
Vitest включает bench() из коробки для тестирования производительности. Не нужна отдельная библиотека:
import { bench, describe } from "vitest";
describe("string concatenation", () => {
bench("template literals", () => {
const name = "world";
const _result = `hello ${name}`;
});
bench("string concat", () => {
const name = "world";
const _result = "hello " + name;
});
});Запускается через vitest bench. Это не главная функция, но приятно иметь тестирование производительности в том же инструментарии, не устанавливая benchmark.js и не настраивая отдельный раннер.
Настройка#
Установка#
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdomКонфигурация#
Создайте vitest.config.ts в корне проекта:
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
alias: {
"@": path.resolve(__dirname, "./src"),
},
css: false,
},
});Или, если у вас уже есть vite.config.ts, можно расширить его:
/// <reference types="vitest/config" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": "/src",
},
},
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
});Решение о globals: true#
Когда globals установлен в true, вам не нужно импортировать describe, it, expect, beforeEach и т.д. — они доступны везде, как в Jest. Когда false, вы импортируете их явно:
// globals: false
import { describe, it, expect } from "vitest";
describe("math", () => {
it("adds numbers", () => {
expect(1 + 1).toBe(2);
});
});Я использую globals: true, потому что это уменьшает визуальный шум и соответствует ожиданиям большинства разработчиков. Если ваша команда ценит явные импорты — установите false, здесь нет неправильного ответа.
Если вы используете globals: true, добавьте типы Vitest в ваш tsconfig.json, чтобы TypeScript их распознавал:
{
"compilerOptions": {
"types": ["vitest/globals"]
}
}Среда: jsdom vs happy-dom vs node#
Vitest позволяет выбрать реализацию DOM для каждого теста или глобально:
node— Без DOM. Для чистой логики, утилит, API-маршрутов и всего, что не касается браузера.jsdom— Стандарт. Полная реализация DOM. Тяжелее, но полнее.happy-dom— Легче и быстрее, чем jsdom, но менее полный. Некоторые крайние случаи (вродеRange,SelectionилиIntersectionObserver) могут не работать.
По умолчанию я использую jsdom глобально и переопределяю для отдельных файлов, когда нужен node:
// src/lib/utils.test.ts
// @vitest-environment node
import { formatDate, slugify } from "./utils";
describe("slugify", () => {
it("converts spaces to hyphens", () => {
expect(slugify("hello world")).toBe("hello-world");
});
});Файлы настройки#
Файл настройки запускается перед каждым тестовым файлом. Здесь вы настраиваете матчеры Testing Library и глобальные моки:
// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
// Мок IntersectionObserver — jsdom его не реализует
class MockIntersectionObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
value: MockIntersectionObserver,
});
// Мок window.matchMedia — нужен для адаптивных компонентов
Object.defineProperty(window, "matchMedia", {
writable: true,
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});Импорт @testing-library/jest-dom/vitest даёт вам матчеры вроде toBeInTheDocument(), toHaveClass(), toBeVisible() и многие другие. Они делают утверждения для DOM-элементов читаемыми и выразительными.
Написание хороших тестов#
Паттерн AAA#
Каждый тест следует одной и той же структуре: Arrange (Подготовка), Act (Действие), Assert (Проверка). Даже без явных комментариев структура должна быть видна:
it("calculates total with tax", () => {
// Arrange
const items = [
{ name: "Widget", price: 10 },
{ name: "Gadget", price: 20 },
];
const taxRate = 0.08;
// Act
const total = calculateTotal(items, taxRate);
// Assert
expect(total).toBe(32.4);
});Когда я вижу тест, в котором подготовка, действие и проверка смешаны в одну цепочку вызовов, я знаю, что его будет трудно понять при падении. Держите три фазы визуально разделёнными, даже если не добавляете комментарии.
Именование тестов#
Есть две школы: it('should calculate total with tax') и it('calculates total with tax'). Префикс «should» многословен без добавления информации. Когда тест падает, вы увидите:
FAIL ✕ calculates total with tax
Это уже законченное предложение. Добавление «should» только добавляет шум. Я предпочитаю прямую форму: it('renders loading state'), it('rejects invalid email'), it('returns empty array when no matches found').
Для блоков describe используйте имя тестируемой единицы:
describe("calculateTotal", () => {
it("sums item prices", () => { /* ... */ });
it("applies tax rate", () => { /* ... */ });
it("returns 0 for empty array", () => { /* ... */ });
it("handles negative prices", () => { /* ... */ });
});Прочитайте вслух: «calculateTotal sums item prices.» «calculateTotal applies tax rate.» Если предложение звучит — именование работает.
Одно утверждение на тест vs практическая группировка#
Пуристское правило гласит: одно утверждение на тест. Практическое правило: одна концепция на тест. Это разные вещи.
// Это нормально — одна концепция, несколько утверждений
it("formats user display name", () => {
const user = { firstName: "John", lastName: "Doe", title: "Dr." };
const result = formatDisplayName(user);
expect(result).toContain("John");
expect(result).toContain("Doe");
expect(result).toStartWith("Dr.");
});
// Это плохо — несколько концепций в одном тесте
it("handles user operations", () => {
const user = createUser("John");
expect(user.id).toBeDefined();
const updated = updateUser(user.id, { name: "Jane" });
expect(updated.name).toBe("Jane");
deleteUser(user.id);
expect(getUser(user.id)).toBeNull();
});В первом тесте три утверждения, но все они проверяют одно: формат отображаемого имени. Если любое утверждение упадёт, вы точно знаете, что сломалось. Второй тест — это три отдельных теста, запиханных вместе. Если второе утверждение упадёт, непонятно, создание или обновление сломано, а третье утверждение вообще не выполнится.
Описания тестов как документация#
Хорошие тестовые наборы служат живой документацией. Человек, незнакомый с кодом, должен прочитать описания тестов и понять поведение функциональности:
describe("PasswordValidator", () => {
describe("minimum length", () => {
it("rejects passwords shorter than 8 characters", () => { /* ... */ });
it("accepts passwords with exactly 8 characters", () => { /* ... */ });
});
describe("character requirements", () => {
it("requires at least one uppercase letter", () => { /* ... */ });
it("requires at least one number", () => { /* ... */ });
it("requires at least one special character", () => { /* ... */ });
});
describe("common password check", () => {
it("rejects passwords in the common passwords list", () => { /* ... */ });
it("performs case-insensitive comparison", () => { /* ... */ });
});
});Когда этот тестовый набор запускается, вывод читается как спецификация. В этом и цель.
Мокирование#
Мокирование — самый мощный и самый опасный инструмент в вашем тестовом арсенале. При грамотном использовании оно изолирует тестируемую единицу и делает тесты быстрыми и детерминированными. При плохом использовании — создаёт тесты, которые проходят независимо от того, что делает код.
vi.fn() — Создание мок-функций#
Простейший мок — это функция, которая записывает свои вызовы:
const mockCallback = vi.fn();
// Вызываем
mockCallback("hello", 42);
mockCallback("world");
// Проверяем вызовы
expect(mockCallback).toHaveBeenCalledTimes(2);
expect(mockCallback).toHaveBeenCalledWith("hello", 42);
expect(mockCallback).toHaveBeenLastCalledWith("world");Можно задать возвращаемое значение:
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ id: 1, name: "Test" }),
});Или заставить возвращать разные значения при последовательных вызовах:
const mockRandom = vi.fn()
.mockReturnValueOnce(0.1)
.mockReturnValueOnce(0.5)
.mockReturnValueOnce(0.9);vi.spyOn() — Наблюдение за реальными методами#
Когда вы хотите наблюдать за методом, не заменяя его поведение:
const consoleSpy = vi.spyOn(console, "warn");
validateInput("");
expect(consoleSpy).toHaveBeenCalledWith("Input cannot be empty");
consoleSpy.mockRestore();spyOn по умолчанию сохраняет оригинальную реализацию. Вы можете переопределить её через .mockImplementation() при необходимости, но восстановите оригинал после через .mockRestore().
vi.mock() — Мокирование на уровне модулей#
Это главное. vi.mock() заменяет целый модуль:
// Мокируем весь модуль
vi.mock("@/lib/api", () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: "Alice" },
{ id: 2, name: "Bob" },
]),
fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Alice" }),
}));
// Импорт теперь использует замокированную версию
import { fetchUsers } from "@/lib/api";
describe("UserList", () => {
it("displays users from API", async () => {
const users = await fetchUsers();
expect(users).toHaveLength(2);
});
});Vitest автоматически поднимает вызовы vi.mock() в начало файла. Это означает, что мок установлен до выполнения любых импортов. Не нужно беспокоиться о порядке импортов.
Автоматическое мокирование#
Если вы просто хотите, чтобы каждый экспорт был заменён на vi.fn():
vi.mock("@/lib/analytics");
import { trackEvent, trackPageView } from "@/lib/analytics";
it("tracks form submission", () => {
submitForm();
expect(trackEvent).toHaveBeenCalledWith("form_submit", expect.any(Object));
});Без фабричной функции Vitest автоматически мокирует все экспорты. Каждая экспортированная функция становится vi.fn(), возвращающим undefined. Это полезно для модулей, которые вы хотите заглушить (вроде аналитики или логирования), не указывая каждую функцию.
Очистка vs сброс vs восстановление#
Это рано или поздно сбивает с толку всех:
const mockFn = vi.fn().mockReturnValue(42);
mockFn();
// mockClear — сбрасывает историю вызовов, сохраняет реализацию
mockFn.mockClear();
expect(mockFn).not.toHaveBeenCalled(); // true
expect(mockFn()).toBe(42); // всё ещё возвращает 42
// mockReset — сбрасывает историю вызовов И реализацию
mockFn.mockReset();
expect(mockFn()).toBeUndefined(); // больше не возвращает 42
// mockRestore — для шпионов, восстанавливает оригинальную реализацию
const spy = vi.spyOn(Math, "random").mockReturnValue(0.5);
spy.mockRestore();
// Math.random() снова работает нормальноНа практике используйте vi.clearAllMocks() в beforeEach для сброса истории вызовов между тестами. Используйте vi.restoreAllMocks(), если вы используете spyOn и хотите вернуть оригиналы:
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});Опасность чрезмерного мокирования#
Это самый важный совет по мокированию, который я могу дать: каждый мок — это ложь, которую вы говорите своему тесту. Когда вы мокируете зависимость, вы говорите: «Я верю, что эта штука работает правильно, поэтому заменю её упрощённой версией». Если ваше предположение неверно, тест проходит, но функциональность сломана.
// Слишком замокировано — не тестирует ничего полезного
vi.mock("@/lib/validator");
vi.mock("@/lib/formatter");
vi.mock("@/lib/api");
vi.mock("@/lib/cache");
it("processes user input", () => {
processInput("hello");
expect(validator.validate).toHaveBeenCalledWith("hello");
expect(formatter.format).toHaveBeenCalledWith("hello");
});Этот тест проверяет, что processInput вызывает validate и format. Но что, если processInput вызывает их в неправильном порядке? Что, если он игнорирует их возвращаемые значения? Что, если валидация должна предотвращать выполнение форматирования? Тест не знает. Вы замокировали всё интересное поведение.
Правило: мокируйте на границах, а не посередине. Мокируйте сетевые запросы, доступ к файловой системе и сторонние сервисы. Не мокируйте свои собственные утилитарные функции, если на то нет веской причины (например, они дорого выполняются или имеют побочные эффекты).
Тестирование React-компонентов#
Основы с Testing Library#
Testing Library навязывает философию: тестируйте компоненты так, как с ними взаимодействуют пользователи. Никакой проверки внутреннего состояния, никакого исследования экземпляров компонентов, никакого поверхностного рендеринга. Вы рендерите компонент и взаимодействуете с ним через DOM, как это делал бы пользователь.
import { render, screen } from "@testing-library/react";
import { Button } from "@/components/ui/Button";
describe("Button", () => {
it("renders with label text", () => {
render(<Button>Click me</Button>);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it("applies variant classes", () => {
render(<Button variant="primary">Submit</Button>);
const button = screen.getByRole("button");
expect(button).toHaveClass("bg-primary");
});
it("is disabled when disabled prop is true", () => {
render(<Button disabled>Submit</Button>);
expect(screen.getByRole("button")).toBeDisabled();
});
});Запросы: getBy vs queryBy vs findBy#
Вот где новички путаются. Есть три варианта запросов, и у каждого своё назначение:
getBy* — Возвращает элемент или выбрасывает ошибку, если не найден. Используйте, когда ожидаете, что элемент существует:
// Выбрасывает ошибку, если кнопка не найдена — тест падает с полезным сообщением
const button = screen.getByRole("button", { name: "Submit" });queryBy* — Возвращает элемент или null, если не найден. Используйте, когда проверяете, что чего-то НЕТ:
// Возвращает null — не выбрасывает ошибку
expect(screen.queryByText("Error message")).not.toBeInTheDocument();findBy* — Возвращает Promise. Используйте для элементов, которые появляются асинхронно:
// Ждёт до 1000мс, пока элемент не появится
const successMessage = await screen.findByText("Saved successfully");
expect(successMessage).toBeInTheDocument();Запросы с приоритетом доступности#
Testing Library предоставляет запросы в продуманном порядке приоритета:
getByRole— Лучший запрос. Использует ARIA-роли. Если ваш компонент не находится по роли, возможно, у него проблема с доступностью.getByLabelText— Для элементов форм. Если у вашего поля ввода нет метки, сначала исправьте это.getByPlaceholderText— Допустимо, но слабее. Плейсхолдеры исчезают, когда пользователь начинает вводить.getByText— Для неинтерактивных элементов. Находит по видимому текстовому содержимому.getByTestId— Последний вариант. Используйте, когда ни один семантический запрос не работает.
// Предпочтительно
screen.getByRole("textbox", { name: "Email address" });
// Хуже
screen.getByPlaceholderText("Enter your email");
// И точно хуже этого
screen.getByTestId("email-input");Ранжирование не произвольное. Оно соответствует тому, как вспомогательные технологии навигируют по странице. Если вы можете найти элемент по его роли и доступному имени, экранные читалки тоже смогут. Если найти можно только по test ID, возможно, у вас есть проблема с доступностью.
Пользовательские события#
Не используйте fireEvent. Используйте @testing-library/user-event. Разница имеет значение:
import userEvent from "@testing-library/user-event";
describe("SearchInput", () => {
it("filters results as user types", async () => {
const user = userEvent.setup();
const onSearch = vi.fn();
render(<SearchInput onSearch={onSearch} />);
const input = screen.getByRole("searchbox");
await user.type(input, "vitest");
// user.type вызывает keydown, keypress, input, keyup для КАЖДОГО символа
// fireEvent.change просто устанавливает значение — пропуская реалистичный поток событий
expect(onSearch).toHaveBeenLastCalledWith("vitest");
});
it("clears input on escape key", async () => {
const user = userEvent.setup();
render(<SearchInput onSearch={vi.fn()} />);
const input = screen.getByRole("searchbox");
await user.type(input, "hello");
await user.keyboard("{Escape}");
expect(input).toHaveValue("");
});
});userEvent симулирует полную цепочку событий, которую вызвал бы настоящий браузер. fireEvent.change — это одно синтетическое событие. Если ваш компонент слушает onKeyDown или использует onInput вместо onChange, fireEvent.change не вызовет эти обработчики, а userEvent.type — вызовет.
Всегда вызывайте userEvent.setup() в начале и используйте возвращённый экземпляр user. Это обеспечивает правильный порядок событий и отслеживание состояния.
Тестирование взаимодействий компонентов#
Реалистичный тест компонента выглядит так:
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "@/components/TodoList";
describe("TodoList", () => {
it("adds a new todo item", async () => {
const user = userEvent.setup();
render(<TodoList />);
const input = screen.getByRole("textbox", { name: /new todo/i });
const addButton = screen.getByRole("button", { name: /add/i });
await user.type(input, "Write tests");
await user.click(addButton);
expect(screen.getByText("Write tests")).toBeInTheDocument();
expect(input).toHaveValue("");
});
it("marks a todo as completed", async () => {
const user = userEvent.setup();
render(<TodoList initialItems={[{ id: "1", text: "Buy groceries", done: false }]} />);
const checkbox = screen.getByRole("checkbox", { name: /buy groceries/i });
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
it("removes completed items when clear button is clicked", async () => {
const user = userEvent.setup();
render(
<TodoList
initialItems={[
{ id: "1", text: "Done task", done: true },
{ id: "2", text: "Pending task", done: false },
]}
/>
);
await user.click(screen.getByRole("button", { name: /clear completed/i }));
expect(screen.queryByText("Done task")).not.toBeInTheDocument();
expect(screen.getByText("Pending task")).toBeInTheDocument();
});
});Обратите внимание: никакой проверки внутреннего состояния, никакого component.setState(), никакой проверки деталей реализации. Мы рендерим, взаимодействуем и проверяем то, что увидел бы пользователь. Если компонент перестроит внутреннее управление состоянием с useState на useReducer, эти тесты всё равно пройдут. В этом и суть.
Тестирование асинхронного кода#
waitFor#
Когда компонент обновляется асинхронно, waitFor опрашивает до тех пор, пока утверждение не пройдёт:
import { render, screen, waitFor } from "@testing-library/react";
it("loads and displays user profile", async () => {
render(<UserProfile userId="123" />);
// Изначально показывает загрузку
expect(screen.getByText("Loading...")).toBeInTheDocument();
// Ждём появления контента
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
// Индикатор загрузки должен исчезнуть
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});waitFor повторяет колбэк каждые 50мс (по умолчанию), пока он не пройдёт или не истечёт таймаут (1000мс по умолчанию). Оба параметра настраиваются:
await waitFor(
() => expect(screen.getByText("Done")).toBeInTheDocument(),
{ timeout: 3000, interval: 100 }
);Фейковые таймеры#
При тестировании кода, использующего setTimeout, setInterval или Date, фейковые таймеры позволяют управлять временем:
describe("Debounce", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("delays execution by the specified time", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
it("resets timer on subsequent calls", () => {
const callback = vi.fn();
const debounced = debounce(callback, 300);
debounced();
vi.advanceTimersByTime(200);
debounced(); // сбрасываем таймер
vi.advanceTimersByTime(200);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(callback).toHaveBeenCalledOnce();
});
});Важно: всегда вызывайте vi.useRealTimers() в afterEach. Фейковые таймеры, просачивающиеся между тестами, вызывают самые запутанные ошибки, которые вы когда-либо отлаживали.
Тестирование с фейковыми таймерами и асинхронным рендерингом#
Сочетание фейковых таймеров с тестированием React-компонентов требует осторожности. Внутреннее планирование React использует реальные таймеры, поэтому часто нужно продвигать таймеры И сбрасывать обновления React одновременно:
import { render, screen, act } from "@testing-library/react";
describe("Notification", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("auto-dismisses after 5 seconds", async () => {
render(<Notification message="Saved!" autoDismiss={5000} />);
expect(screen.getByText("Saved!")).toBeInTheDocument();
// Продвигаем таймеры внутри act() для сброса обновлений React
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
});
});Мокирование API с помощью MSW#
Для тестирования получения данных Mock Service Worker (MSW) перехватывает сетевые запросы на сетевом уровне. Это значит, что ваш реальный код fetch/axios выполняется точно так, как в продакшене — MSW просто подменяет сетевой ответ:
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { render, screen } from "@testing-library/react";
const server = setupServer(
http.get("/api/users", () => {
return HttpResponse.json([
{ id: 1, name: "Alice", email: "alice@example.com" },
{ id: 2, name: "Bob", email: "bob@example.com" },
]);
}),
http.get("/api/users/:id", ({ params }) => {
return HttpResponse.json({
id: Number(params.id),
name: "Alice",
email: "alice@example.com",
});
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("UserList", () => {
it("displays users from API", async () => {
render(<UserList />);
expect(await screen.findByText("Alice")).toBeInTheDocument();
expect(await screen.findByText("Bob")).toBeInTheDocument();
});
it("shows error state when API fails", async () => {
// Переопределяем обработчик по умолчанию для этого теста
server.use(
http.get("/api/users", () => {
return new HttpResponse(null, { status: 500 });
})
);
render(<UserList />);
expect(await screen.findByText(/failed to load/i)).toBeInTheDocument();
});
});MSW лучше, чем прямое мокирование fetch или axios, потому что:
- Ваш реальный код получения данных выполняется — вы тестируете настоящую интеграцию.
- Вы можете тестировать обработку ошибок, переопределяя обработчики для каждого теста.
- Те же обработчики работают и в тестах, и в режиме разработки в браузере (например, в Storybook).
Интеграционные тесты#
Юнит-тесты vs интеграционные тесты#
Юнит-тест изолирует одну функцию или компонент и мокирует всё остальное. Интеграционный тест позволяет нескольким единицам работать вместе и мокирует только внешние границы (сеть, файловую систему, базы данных).
Правда в том, что большинство багов, которые я видел в продакшене, происходят на границах между единицами, а не внутри них. Функция прекрасно работает изолированно, но ломается, потому что вызывающий код передаёт данные в слегка другом формате. Компонент отлично рендерится с мок-данными, но ломается, когда в реальном ответе API есть дополнительный уровень вложенности.
Интеграционные тесты ловят эти баги. Они медленнее юнит-тестов и сложнее в отладке при падении, но дают больше уверенности на каждый тест.
Тестирование нескольких компонентов вместе#
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ShoppingCart } from "@/components/ShoppingCart";
import { CartProvider } from "@/contexts/CartContext";
// Мокируем только API-слой — всё остальное настоящее
vi.mock("@/lib/api", () => ({
checkout: vi.fn().mockResolvedValue({ orderId: "ORD-123" }),
}));
describe("Shopping Cart Flow", () => {
const renderCart = (initialItems = []) => {
return render(
<CartProvider initialItems={initialItems}>
<ShoppingCart />
</CartProvider>
);
};
it("displays item count and total", () => {
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
{ id: "2", name: "Mouse", price: 49.99, quantity: 2 },
]);
expect(screen.getByText("3 items")).toBeInTheDocument();
expect(screen.getByText("$179.97")).toBeInTheDocument();
});
it("updates quantity and recalculates total", async () => {
const user = userEvent.setup();
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
const incrementButton = screen.getByRole("button", { name: /increase quantity/i });
await user.click(incrementButton);
expect(screen.getByText("$159.98")).toBeInTheDocument();
});
it("completes checkout flow", async () => {
const user = userEvent.setup();
const { checkout } = await import("@/lib/api");
renderCart([
{ id: "1", name: "Keyboard", price: 79.99, quantity: 1 },
]);
await user.click(screen.getByRole("button", { name: /checkout/i }));
expect(checkout).toHaveBeenCalledWith({
items: [{ id: "1", quantity: 1 }],
});
expect(await screen.findByText(/order confirmed/i)).toBeInTheDocument();
expect(screen.getByText("ORD-123")).toBeInTheDocument();
});
});В этом тесте ShoppingCart, CartProvider и их внутренние компоненты (строки товаров, селекторы количества, отображение итогов) работают вместе с реальным кодом. Единственный мок — это вызов API, потому что мы не хотим делать реальные сетевые запросы в тестах.
Когда использовать интеграционные vs юнит-тесты#
Используйте юнит-тесты, когда:
- Логика сложная и имеет много граничных случаев (парсер дат, конечный автомат, вычисление).
- Вам нужна быстрая обратная связь по поведению конкретной функции.
- Единица относительно изолирована и не сильно зависит от других единиц.
Используйте интеграционные тесты, когда:
- Несколько компонентов должны правильно работать вместе (форма с валидацией и отправкой).
- Данные проходят через несколько слоёв (контекст -> компонент -> дочерний компонент).
- Вы тестируете пользовательский сценарий, а не возвращаемое значение функции.
На практике здоровый набор тестов содержит много интеграционных тестов для фич и юнит-тесты для сложных утилит. Сами компоненты тестируются через интеграционные тесты — не нужен отдельный юнит-тест для каждого мелкого компонента, если интеграционный тест его задействует.
Покрытие#
Запуск покрытия#
vitest run --coverageВам понадобится провайдер покрытия. Vitest поддерживает два:
# V8 — быстрее, использует встроенное покрытие V8
npm install -D @vitest/coverage-v8
# Istanbul — более зрелый, больше параметров конфигурации
npm install -D @vitest/coverage-istanbulНастройте в конфигурации Vitest:
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.{ts,tsx}"],
exclude: [
"src/**/*.test.{ts,tsx}",
"src/**/*.spec.{ts,tsx}",
"src/test/**",
"src/**/*.d.ts",
"src/**/types.ts",
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});Istanbul vs V8#
Покрытие V8 использует встроенную инструментацию движка V8. Оно быстрее, потому что нет этапа трансформации кода. Но может быть менее точным в некоторых крайних случаях, особенно на границах ES-модулей.
Istanbul инструментирует ваш исходный код счётчиками перед запуском тестов. Он медленнее, но более проверен временем и даёт более точное покрытие ветвей. Если вы применяете пороговые значения покрытия в CI, точность Istanbul может иметь значение.
Я использую V8 для локальной разработки (более быстрая обратная связь) и Istanbul в CI (более точное применение). При необходимости можно настроить разные провайдеры для разных сред.
Что покрытие реально означает#
Покрытие показывает, какие строки кода были выполнены во время тестов. И всё. Оно не говорит, были ли эти строки протестированы правильно. Рассмотрим:
function divide(a: number, b: number): number {
return a / b;
}
it("divides numbers", () => {
divide(10, 2);
// Нет утверждения!
});Этот тест даёт 100% покрытие функции divide. Он также не тестирует абсолютно ничего. Тест прошёл бы, если бы divide вернул null, выбросил ошибку или запустил ракеты.
Покрытие — полезный негативный индикатор: низкое покрытие означает, что определённо есть непротестированные пути. Но высокое покрытие не означает, что ваш код хорошо протестирован. Оно лишь означает, что каждая строка выполнилась в каком-то тесте.
Строки vs ветви#
Покрытие строк — самая распространённая метрика, но покрытие ветвей более ценно:
function getDiscount(user: User): number {
if (user.isPremium) {
return user.yearsActive > 5 ? 0.2 : 0.1;
}
return 0;
}Тест с getDiscount({ isPremium: true, yearsActive: 10 }) затрагивает каждую строку (100% покрытие строк), но тестирует только две из трёх ветвей. Путь isPremium: false и путь yearsActive <= 5 не протестированы.
Покрытие ветвей это ловит. Оно отслеживает каждый возможный путь через условную логику. Если вы собираетесь применять порог покрытия, используйте покрытие ветвей.
Исключение сгенерированного кода#
Некоторый код не должен учитываться в покрытии. Сгенерированные файлы, определения типов, конфигурация — это раздувает ваши метрики без добавления ценности:
// vitest.config.ts
coverage: {
exclude: [
"src/**/*.d.ts",
"src/**/types.ts",
"src/**/*.stories.tsx",
"src/generated/**",
".velite/**",
],
}Вы также можете игнорировать конкретные строки или блоки в исходном коде:
/* v8 ignore start */
if (process.env.NODE_ENV === "development") {
console.log("Debug info:", data);
}
/* v8 ignore stop */
// Или для Istanbul
/* istanbul ignore next */
function devOnlyHelper() { /* ... */ }Используйте это с осторожностью. Если вы обнаруживаете, что игнорируете большие куски кода, либо эти куски нуждаются в тестах, либо их вообще не должно быть в отчёте о покрытии.
Тестирование Next.js#
Мокирование next/navigation#
Компоненты Next.js, использующие useRouter, usePathname или useSearchParams, нуждаются в моках:
import { vi } from "vitest";
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
}),
usePathname: () => "/en/blog",
useSearchParams: () => new URLSearchParams("?page=1"),
useParams: () => ({ locale: "en" }),
}));Для тестов, где нужно проверить, что навигация была вызвана:
import { useRouter } from "next/navigation";
vi.mock("next/navigation", () => ({
useRouter: vi.fn(),
}));
describe("LogoutButton", () => {
it("redirects to home after logout", async () => {
const mockPush = vi.fn();
vi.mocked(useRouter).mockReturnValue({
push: mockPush,
replace: vi.fn(),
back: vi.fn(),
prefetch: vi.fn(),
refresh: vi.fn(),
forward: vi.fn(),
});
const user = userEvent.setup();
render(<LogoutButton />);
await user.click(screen.getByRole("button", { name: /log out/i }));
expect(mockPush).toHaveBeenCalledWith("/");
});
});Мокирование next-intl#
Для интернационализированных компонентов, использующих next-intl:
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
useLocale: () => "en",
}));Это простейший подход — переводы возвращают сам ключ, поэтому t("hero.title") вернёт "hero.title". В утверждениях вы проверяете ключ перевода, а не фактическую переведённую строку. Это делает тесты независимыми от языка.
Если в конкретном тесте нужны реальные переводы:
vi.mock("next-intl", () => ({
useTranslations: () => {
const translations: Record<string, string> = {
"hero.title": "Welcome to My Site",
"hero.subtitle": "Building things for the web",
};
return (key: string) => translations[key] ?? key;
},
}));Тестирование обработчиков маршрутов#
Обработчики маршрутов Next.js — это обычные функции, которые принимают Request и возвращают Response. Их просто тестировать:
import { GET, POST } from "@/app/api/users/route";
import { NextRequest } from "next/server";
describe("GET /api/users", () => {
it("returns users list", async () => {
const request = new NextRequest("http://localhost:3000/api/users");
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: expect.any(Number), name: expect.any(String) }),
])
);
});
it("supports pagination via search params", async () => {
const request = new NextRequest("http://localhost:3000/api/users?page=2&limit=10");
const response = await GET(request);
const data = await response.json();
expect(data.page).toBe(2);
expect(data.items).toHaveLength(10);
});
});
describe("POST /api/users", () => {
it("creates a new user", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "Alice", email: "alice@test.com" }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.name).toBe("Alice");
});
it("returns 400 for invalid body", async () => {
const request = new NextRequest("http://localhost:3000/api/users", {
method: "POST",
body: JSON.stringify({ name: "" }),
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});Тестирование Middleware#
Middleware Next.js работает на границе (edge) и обрабатывает каждый запрос. Тестируйте его как функцию:
import { middleware } from "@/middleware";
import { NextRequest } from "next/server";
function createRequest(path: string, headers: Record<string, string> = {}): NextRequest {
const url = new URL(path, "http://localhost:3000");
return new NextRequest(url, { headers });
}
describe("middleware", () => {
it("redirects unauthenticated users from protected routes", async () => {
const request = createRequest("/dashboard");
const response = await middleware(request);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain("/login");
});
it("allows authenticated users through", async () => {
const request = createRequest("/dashboard", {
cookie: "session=valid-token",
});
const response = await middleware(request);
expect(response.status).toBe(200);
});
it("adds security headers", async () => {
const request = createRequest("/");
const response = await middleware(request);
expect(response.headers.get("x-frame-options")).toBe("DENY");
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
});
it("handles locale detection", async () => {
const request = createRequest("/", {
"accept-language": "tr-TR,tr;q=0.9,en;q=0.8",
});
const response = await middleware(request);
expect(response.headers.get("location")).toContain("/tr");
});
});Тестирование серверных действий#
Серверные действия — это асинхронные функции, выполняющиеся на сервере. Поскольку это просто функции, вы можете тестировать их напрямую, но, возможно, потребуется замокировать серверные зависимости:
vi.mock("@/lib/db", () => ({
db: {
user: {
update: vi.fn().mockResolvedValue({ id: "1", name: "Updated" }),
findUnique: vi.fn().mockResolvedValue({ id: "1", name: "Original" }),
},
},
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
}));
import { updateProfile } from "@/app/actions/profile";
import { revalidatePath } from "next/cache";
describe("updateProfile", () => {
it("updates user and revalidates profile page", async () => {
const formData = new FormData();
formData.set("name", "New Name");
formData.set("bio", "New bio text");
const result = await updateProfile(formData);
expect(result.success).toBe(true);
expect(revalidatePath).toHaveBeenCalledWith("/profile");
});
it("returns error for invalid data", async () => {
const formData = new FormData();
// Отсутствуют обязательные поля
const result = await updateProfile(formData);
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
});
});Практические паттерны#
Пользовательская функция рендеринга#
Большинству проектов нужны одни и те же провайдеры, обёрнутые вокруг каждого компонента. Создайте пользовательский рендер:
// src/test/utils.tsx
import { render, type RenderOptions } from "@testing-library/react";
import { ReactElement } from "react";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { CartProvider } from "@/contexts/CartContext";
interface CustomRenderOptions extends RenderOptions {
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}
function AllProviders({ children, theme = "light", initialCartItems = [] }: {
children: React.ReactNode;
theme?: "light" | "dark";
initialCartItems?: CartItem[];
}) {
return (
<ThemeProvider defaultTheme={theme}>
<CartProvider initialItems={initialCartItems}>
{children}
</CartProvider>
</ThemeProvider>
);
}
export function renderWithProviders(
ui: ReactElement,
options: CustomRenderOptions = {}
) {
const { theme, initialCartItems, ...renderOptions } = options;
return render(ui, {
wrapper: ({ children }) => (
<AllProviders theme={theme} initialCartItems={initialCartItems}>
{children}
</AllProviders>
),
...renderOptions,
});
}
// Реэкспортируем всё из testing library
export * from "@testing-library/react";
export { renderWithProviders as render };Теперь каждый тестовый файл импортирует из ваших пользовательских утилит вместо @testing-library/react:
import { render, screen } from "@/test/utils";
it("renders in dark mode", () => {
render(<Header />, { theme: "dark" });
// Header и все его дочерние элементы имеют доступ к ThemeProvider и CartProvider
});Тестирование пользовательских хуков#
Vitest работает с renderHook из @testing-library/react:
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "@/hooks/useCounter";
describe("useCounter", () => {
it("starts at initial value", () => {
const { result } = renderHook(() => useCounter(10));
expect(result.current.count).toBe(10);
});
it("increments", () => {
const { result } = renderHook(() => useCounter(0));
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
it("decrements with floor", () => {
const { result } = renderHook(() => useCounter(0, { min: 0 }));
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0); // не опускается ниже min
});
});Тестирование границ ошибок#
import { render, screen } from "@testing-library/react";
import { ErrorBoundary } from "@/components/ErrorBoundary";
const ThrowingComponent = () => {
throw new Error("Test explosion");
};
describe("ErrorBoundary", () => {
// Подавляем console.error для ожидаемых ошибок
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("displays fallback UI when child throws", () => {
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowingComponent />
</ErrorBoundary>
);
expect(screen.getByText("Something went wrong")).toBeInTheDocument();
});
it("renders children when no error", () => {
render(
<ErrorBoundary fallback={<div>Error</div>}>
<div>All good</div>
</ErrorBoundary>
);
expect(screen.getByText("All good")).toBeInTheDocument();
expect(screen.queryByText("Error")).not.toBeInTheDocument();
});
});Snapshot-тестирование (с осторожностью)#
Snapshot-тесты имеют плохую репутацию, потому что люди используют их как замену настоящим утверждениям. Снапшот всего HTML-вывода компонента — это бремя поддержки: он ломается при каждом изменении CSS-класса, и никто не просматривает diff внимательно.
Но целевые снапшоты могут быть полезны:
import { render } from "@testing-library/react";
import { formatCurrency } from "@/lib/format";
// Хорошо — маленький, целевой снапшот вывода чистой функции
it("formats various currency values consistently", () => {
expect(formatCurrency(0)).toMatchInlineSnapshot('"$0.00"');
expect(formatCurrency(1234.5)).toMatchInlineSnapshot('"$1,234.50"');
expect(formatCurrency(-99.99)).toMatchInlineSnapshot('"-$99.99"');
expect(formatCurrency(1000000)).toMatchInlineSnapshot('"$1,000,000.00"');
});
// Плохо — гигантский снапшот, который никто не проверит
it("renders the dashboard", () => {
const { container } = render(<Dashboard />);
expect(container).toMatchSnapshot(); // Не делайте так
});Инлайн-снапшоты (toMatchInlineSnapshot) лучше файловых снапшотов, потому что ожидаемое значение видно прямо в тесте. Вы можете с первого взгляда увидеть, что возвращает функция, не открывая отдельный .snap-файл.
Философия тестирования#
Тестируйте поведение, а не реализацию#
Этот принцип настолько важен, что заслуживает отдельного раздела. Рассмотрим два теста для одной функциональности:
// Тест реализации — хрупкий, ломается при рефакторинге
it("calls setState with new count", () => {
const setStateSpy = vi.spyOn(React, "useState");
render(<Counter />);
fireEvent.click(screen.getByText("+"));
expect(setStateSpy).toHaveBeenCalledWith(expect.any(Function));
});
// Тест поведения — устойчивый, тестирует то, что видит пользователь
it("increments the displayed count when plus button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "+" }));
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});Первый тест ломается, если вы переключитесь с useState на useReducer, хотя компонент работает точно так же. Второй тест ломается только если поведение компонента реально изменилось. Ему неважно, как управляется счётчик внутри — только то, что нажатие на «+» увеличивает число.
Лакмусовая бумажка проста: можете ли вы провести рефакторинг реализации, не меняя тест? Если да — вы тестируете поведение. Если нет — вы тестируете реализацию.
Трофей тестирования#
Кент Доддс предложил «Трофей тестирования» как альтернативу традиционной пирамиде тестирования:
╭─────────╮
│ E2E │ Мало — дорогие, медленные, высокая уверенность
├─────────┤
│ │
│ Интегр. │ Больше всего — хорошее соотношение уверенности к затратам
│ │
├─────────┤
│ Юнит │ Немного — быстрые, сфокусированные, низкие затраты
├─────────┤
│ Статич. │ Всегда — TypeScript, ESLint
╰─────────╯
Традиционная пирамида ставит юнит-тесты в основание (много) и интеграционные в середину (меньше). Трофей инвертирует это: интеграционные тесты — золотая середина. Вот почему:
- Статический анализ (TypeScript, ESLint) ловит опечатки, неправильные типы и простые логические ошибки бесплатно. Даже не нужно ничего запускать.
- Юнит-тесты отлично подходят для сложной чистой логики, но не говорят, работают ли части вместе.
- Интеграционные тесты проверяют, что компоненты, хуки и контексты работают вместе. Они дают наибольшую уверенность на каждый написанный тест.
- End-to-end тесты проверяют всю систему, но медленные, нестабильные и дорогие в поддержке. Вам нужно несколько для критических путей, но не сотни.
На практике я следую такому распределению: TypeScript ловит большинство моих ошибок типов, я пишу юнит-тесты для сложных утилит и алгоритмов, интеграционные тесты для фич и пользовательских сценариев, и горстку E2E-тестов для критического пути (регистрация, покупка, основной рабочий процесс).
Что даёт уверенность vs что тратит время впустую#
Тесты существуют, чтобы дать вам уверенность для деплоя. Не уверенность, что каждая строка кода выполняется, а уверенность, что приложение работает для пользователей. Это разные вещи.
Высокая уверенность, высокая ценность:
- Интеграционный тест процесса оформления заказа — покрывает валидацию формы, вызовы API, обновления состояния и UI успеха/ошибки.
- Юнит-тест функции расчёта цены с граничными случаями — числа с плавающей точкой, округление, скидки, нулевые/отрицательные значения.
- Тест, что защищённые маршруты перенаправляют неаутентифицированных пользователей.
Низкая уверенность, трата времени:
- Snapshot-тест статической маркетинговой страницы — ломается при каждом изменении текста, не ловит ничего значимого.
- Юнит-тест, что компонент передаёт проп дочернему элементу — тестирует сам React, а не ваш код.
- Тест, что
useStateвызывается — тестирует фреймворк, а не поведение. - 100% покрытие файла конфигурации — это статические данные, TypeScript уже проверяет их структуру.
Вопрос, который стоит задать перед написанием теста: «Если бы этого теста не было, какой баг мог бы попасть в продакшен?» Если ответ «никакой, который TypeScript бы не поймал» или «никакой, который кто-то бы заметил», тест, вероятно, не стоит писать.
Тестирование как обратная связь по дизайну#
Трудно тестируемый код обычно плохо спроектирован. Если вам нужно замокировать пять вещей, чтобы протестировать одну функцию, у этой функции слишком много зависимостей. Если вы не можете отрендерить компонент без настройки сложных провайдеров контекста, компонент слишком связан со своим окружением.
Тесты — это пользователь вашего кода. Если вашим тестам трудно использовать ваш API, другим разработчикам тоже будет трудно. Когда вы боретесь с настройкой тестов, воспримите это как сигнал к рефакторингу тестируемого кода, а не к добавлению новых моков.
// Трудно тестировать — функция делает слишком много
async function processOrder(orderId: string) {
const order = await db.orders.findById(orderId);
const user = await db.users.findById(order.userId);
const inventory = await checkInventory(order.items);
if (!inventory.available) {
await sendEmail(user.email, "out-of-stock", { items: inventory.unavailable });
return { success: false, reason: "out-of-stock" };
}
const payment = await chargeCard(user.paymentMethod, order.total);
if (!payment.success) {
await sendEmail(user.email, "payment-failed", { error: payment.error });
return { success: false, reason: "payment-failed" };
}
await db.orders.update(orderId, { status: "confirmed" });
await sendEmail(user.email, "order-confirmed", { orderId });
return { success: true };
}
// Легче тестировать — разделённые обязанности
function determineOrderAction(
inventory: InventoryResult,
payment: PaymentResult
): OrderAction {
if (!inventory.available) return { type: "out-of-stock", items: inventory.unavailable };
if (!payment.success) return { type: "payment-failed", error: payment.error };
return { type: "confirmed" };
}Вторая версия — чистая функция. Вы можете протестировать каждую комбинацию результатов инвентаризации и оплаты, не мокируя базу данных, платёжный провайдер или почтовый сервис. Логика оркестрации (получение данных, отправка писем) живёт в тонком слое, который тестируется на уровне интеграции.
В этом настоящая ценность тестирования: не в отлове багов после их написания, а в предотвращении плохих дизайнерских решений до их коммита. Дисциплина написания тестов толкает вас к меньшим функциям, более чётким интерфейсам и более модульной архитектуре. Тесты — побочный эффект. Улучшение дизайна — главное событие.
Пишите тесты, которым доверяете#
Худшее, что может случиться с набором тестов, — не то, что в нём есть пробелы. А то, что люди перестают ему доверять. Набор тестов с несколькими нестабильными тестами, которые случайно падают в CI, учит команду игнорировать красные билды. Как только это происходит, набор тестов хуже, чем бесполезен — он активно даёт ложное чувство безопасности.
Если тест периодически падает — почините его или удалите. Если тест медленный — ускорьте его или перенесите в отдельный набор медленных тестов. Если тест ломается при каждом несвязанном изменении — перепишите его, чтобы тестировать поведение вместо реализации.
Цель — набор тестов, где каждое падение означает, что реально что-то сломано. Когда разработчики доверяют тестам, они запускают их перед каждым коммитом. Когда не доверяют — обходят их через --no-verify и деплоят со скрещёнными пальцами.
Постройте набор тестов, на который вы поставили бы свои выходные. Ничего меньшего не стоит поддерживать.