Unit-тесты и TDD в JavaScript
Юнит-тестирование — проверка небольших изолированных частей программы: функций, методов, классов и модулей. Тест задаёт условия, выполняет код и сравнивает результат с ожидаемым.
import { expect, it } from "vitest";
import { sum } from "./sum.js";
it("складывает два числа", () => {
expect(sum(2, 3)).toBe(5);
});Тесты помогают обнаруживать регрессии, документируют поведение программы и позволяют безопаснее проводить рефакторинг. Они не доказывают отсутствие всех ошибок: качество проверки зависит от выбранных сценариев и утверждений.
Принципы юнит-тестирования
Один тест — одно поведение
Тест должен проверять один понятный сценарий. В нём может быть несколько expect, если они описывают один результат.
it("возвращает итоговую стоимость корзины", () => {
const items = [
{ price: 100, quantity: 2 },
{ price: 50, quantity: 3 },
];
expect(calculateTotal(items)).toBe(350);
});Независимые действия — добавление, удаление и очистку корзины — лучше проверять отдельными тестами.
Arrange — Act — Assert
У теста обычно есть три этапа:
- Arrange — подготовка данных и зависимостей.
- Act — выполнение проверяемого действия.
- Assert — проверка результата.
it("применяет скидку", () => {
// Arrange
const price = 1000;
// Act
const result = applyDiscount(price, 20);
// Assert
expect(result).toBe(800);
});Изолированность
Тесты не должны зависеть друг от друга и от порядка запуска. Каждый тест создаёт собственное состояние.
import { beforeEach, describe, expect, it } from "vitest";
describe("Cart", () => {
let cart;
beforeEach(() => {
cart = new Cart();
});
it("изначально пуста", () => {
expect(cart.items).toEqual([]);
});
it("добавляет товар", () => {
cart.add({ id: 1 });
expect(cart.items).toHaveLength(1);
});
});Детерминированность
При одинаковых условиях тест должен давать одинаковый результат. Источники нестабильности — сеть, текущее время, случайные числа, общая база данных и глобальное изменяемое состояние. Такие зависимости передают явно или заменяют тестовыми дублёрами.
// Текущее время можно передать явно.
function isExpired(expiresAt, now = new Date()) {
return new Date(expiresAt) < now;
}Проверка контракта, а не реализации
Лучше проверять возвращаемое значение, ошибку или значимый внешний эффект, а не внутренние шаги функции. Тогда замена filter() на обычный цикл не потребует переписывать тесты.
Читаемые названия
it("возвращает 0 для пустой корзины", () => {});
it("выбрасывает ошибку при отрицательной цене", () => {});
it("округляет сумму до двух знаков", () => {});Названия вроде работает или тест 1 не объясняют назначение сценария.
Границы и ошибки
Кроме основного сценария проверяют пустые коллекции, ноль, отрицательные и предельные значения, неверные данные, исключения и отклонённые Promise.
it.each([
[[], 0],
[[5], 5],
[[1, 2, 3], 6],
[[-5, 5], 0],
])("sum(%j) возвращает %s", (numbers, expected) => {
expect(sum(numbers)).toBe(expected);
});it.each() запускает один сценарий с несколькими наборами данных.
TDD: Red — Green — Refactor
TDD (Test-Driven Development) — подход, при котором тест пишется до реализации поведения.
Red
Написать тест и убедиться, что он падает по ожидаемой причине:
it("уменьшает цену на указанный процент", () => {
expect(applyDiscount(1000, 20)).toBe(800);
});Важно увидеть падение: иначе тест может не проверять новое поведение.
Green
Написать минимальную реализацию, достаточную для прохождения теста:
export function applyDiscount(price, percent) {
return price * (1 - percent / 100);
}Refactor
Улучшить имена и структуру, убрать дублирование, не меняя поведение. После этого добавить следующий тест:
it("отклоняет скидку вне диапазона", () => {
expect(() => applyDiscount(1000, -1)).toThrow(
"Скидка должна быть от 0 до 100",
);
});export function applyDiscount(price, percent) {
if (percent < 0 || percent > 100) {
throw new RangeError("Скидка должна быть от 0 до 100");
}
return price * (1 - percent / 100);
}Цикл выполняется маленькими шагами:
падающий тест → минимальный код → рефакторинг → следующий тестTDD помогает уточнять контракт и проектировать управляемые зависимости, но прохождение тестов не гарантирует правильность самих требований.
Vitest: установка и настройка
Vitest — среда тестирования JavaScript и TypeScript с поддержкой утверждений, хуков, моков, snapshot и coverage.
npm install --save-dev vitest
npm install --save-dev @vitest/coverage-v8Команды в package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}vitest— интерактивный режим наблюдения;vitest run— однократный запуск, удобный для CI;vitest run --coverage— запуск с отчётом покрытия.
Конфигурация vitest.config.js:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.js"],
coverage: {
provider: "v8",
reporter: ["text", "html"],
include: ["src/**/*.js"],
exclude: ["src/**/*.test.js"],
},
},
});describe(), it() и expect()
import { describe, expect, it } from "vitest";
import { sum } from "./math.js";
describe("sum", () => {
it("складывает положительные числа", () => {
expect(sum(2, 3)).toBe(5);
});
it("работает с отрицательными числами", () => {
expect(sum(-2, -3)).toBe(-5);
});
});describe()группирует связанные тесты;it()илиtest()объявляет сценарий;expect()создаёт утверждение.
Основные матчеры
| Матчер | Назначение |
|---|---|
toBe() |
Сравнение через Object.is |
toEqual() |
Глубокое сравнение объектов и массивов |
toStrictEqual() |
Более строгое сравнение структуры |
toContain() |
Наличие элемента или подстроки |
toHaveLength() |
Проверка длины |
toMatch() |
Сопоставление строки или RegExp |
toMatchObject() |
Частичное сравнение объекта |
toBeCloseTo() |
Сравнение дробных чисел |
toThrow() |
Проверка синхронной ошибки |
toHaveBeenCalledWith() |
Проверка аргументов mock/spy |
expect({ id: 1 }).toEqual({ id: 1 });
expect(["js", "css"]).toContain("js");
expect(0.1 + 0.2).toBeCloseTo(0.3);
expect(() => validatePrice(-1)).toThrow(RangeError);Для toThrow() в expect() передаётся функция:
expect(() => validatePrice(-1)).toThrow("Некорректная цена");Хуки
import { afterEach, beforeEach, vi } from "vitest";
beforeEach(() => {
// Подготовка перед каждым тестом.
});
afterEach(() => {
vi.restoreAllMocks();
});Также доступны beforeAll() и afterAll(). Изменяемое состояние безопаснее пересоздавать перед каждым тестом.
Тестирование чистых функций
Чистая функция возвращает одинаковый результат для одинаковых аргументов и не изменяет внешнее состояние или входные данные.
export function getOrderTotal(items) {
return items.reduce(
(total, item) => total + item.price * item.quantity,
0,
);
}import { describe, expect, it } from "vitest";
import { getOrderTotal } from "./order.js";
describe("getOrderTotal", () => {
it("суммирует позиции", () => {
const items = [
{ price: 100, quantity: 2 },
{ price: 50, quantity: 3 },
];
expect(getOrderTotal(items)).toBe(350);
});
it("возвращает 0 для пустого списка", () => {
expect(getOrderTotal([])).toBe(0);
});
});Если функция обязана быть иммутабельной, это проверяют явно:
it("не изменяет исходный массив", () => {
const users = [{ name: "Яна" }, { name: "Анна" }];
const original = structuredClone(users);
const result = sortUsersByName(users);
expect(users).toEqual(original);
expect(result).not.toBe(users);
});Вычисления полезно отделять от побочных эффектов. Чистое преобразование данных тестируется без моков, а сеть или хранилище передаются отдельной зависимостью.
Асинхронные тесты
Тестовая функция может быть async:
it("загружает пользователя", async () => {
const user = await getUser(1);
expect(user).toEqual({ id: 1, name: "Анна" });
});Для Promise доступны resolves и rejects:
it("возвращает пользователя", async () => {
await expect(getUser(1)).resolves.toMatchObject({ id: 1 });
});
it("отклоняет неизвестного пользователя", async () => {
await expect(getUser(999)).rejects.toThrow(
"Пользователь не найден",
);
});Утверждение с resolves или rejects необходимо ожидать через await либо вернуть из теста. Иначе тест может закончиться до проверки.
Моки, стабы и spy
Тестовый дублёр заменяет настоящую зависимость контролируемой.
| Вид | Назначение |
|---|---|
| Stub | Возвращает заранее заданные данные |
| Spy | Записывает сведения о вызовах функции |
| Mock | Имитирует зависимость и проверяет взаимодействие |
| Fake | Даёт упрощённую рабочую реализацию |
Границы терминов могут различаться; в Vitest многие задачи решает объект vi.
Mock-функции: vi.fn()
import { expect, it, vi } from "vitest";
it("передаёт обработчику ID товара", () => {
const onSelect = vi.fn();
selectProduct({ id: 42 }, onSelect);
expect(onSelect).toHaveBeenCalledOnce();
expect(onSelect).toHaveBeenCalledWith(42);
});Настройка результата:
const getRate = vi.fn().mockReturnValue(90);
const loadUser = vi.fn().mockResolvedValue({ id: 1 });
const saveUser = vi.fn().mockRejectedValue(new Error("Ошибка"));Stub через внедрение зависимости
export async function getProfileName(userId, api) {
const user = await api.getUser(userId);
return user.name;
}it("возвращает имя профиля", async () => {
const api = {
getUser: vi.fn().mockResolvedValue({
id: 1,
name: "Анна",
}),
};
await expect(getProfileName(1, api)).resolves.toBe("Анна");
expect(api.getUser).toHaveBeenCalledWith(1);
});Явная передача зависимости упрощает изоляцию и называется dependency injection.
Spy: vi.spyOn()
import { afterEach, expect, it, vi } from "vitest";
afterEach(() => {
vi.restoreAllMocks();
});
it("записывает сообщение об ошибке", () => {
const spy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
reportError("Не удалось сохранить данные");
expect(spy).toHaveBeenCalledWith(
"Не удалось сохранить данные",
);
});Без mockImplementation() spy обычно продолжает вызывать исходный метод.
Подмена модуля: vi.mock()
import { beforeEach, expect, it, vi } from "vitest";
import { getUser } from "./api.js";
import { getUserName } from "./user-service.js";
vi.mock("./api.js", () => ({
getUser: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
});
it("возвращает имя", async () => {
vi.mocked(getUser).mockResolvedValue({ id: 1, name: "Анна" });
await expect(getUserName(1)).resolves.toBe("Анна");
expect(getUser).toHaveBeenCalledWith(1);
});Подмена модулей полезна, но сильнее связывает тест со структурой импортов. Явное внедрение зависимости часто проще.
Очистка моков
vi.clearAllMocks()очищает историю вызовов;vi.resetAllMocks()сбрасывает историю и mock-реализации;vi.restoreAllMocks()восстанавливает оригинальные методы для spy.
Не следует мокировать всё подряд. Простые чистые функции лучше проверять непосредственно. Моки особенно полезны для сети, времени, случайности, файлов, хранилищ и внешних сервисов.
Искусственные таймеры
import { afterEach, expect, it, vi } from "vitest";
afterEach(() => {
vi.useRealTimers();
});
it("вызывает обработчик через секунду", () => {
vi.useFakeTimers();
const callback = vi.fn();
setTimeout(() => callback("Готово"), 1000);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000);
expect(callback).toHaveBeenCalledWith("Готово");
});Искусственные таймеры ускоряют проверку setTimeout и setInterval. После теста реальные таймеры нужно восстановить.
Покрытие кода
Coverage показывает, какой код выполнялся во время тестов.
| Метрика | Что измеряет |
|---|---|
| Statements | Выполненные инструкции |
| Branches | Пройденные ветви условий |
| Functions | Вызванные функции |
| Lines | Выполненные строки |
Запуск:
npm run test:coverageПороговые значения в конфигурации:
coverage: {
provider: "v8",
reporter: ["text", "html"],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
}Высокое покрытие не гарантирует качество. Код может выполниться без содержательных утверждений:
it("вызывает функцию", () => {
calculateTotal(items); // Результат не проверяется.
});Coverage полезен как указатель непроверенных областей. Особое внимание стоит уделять ветвлениям, ошибкам, границам и критическим бизнес-правилам, а не только итоговому проценту.
Snapshot-тестирование
Snapshot сохраняет эталонное представление значения и сравнивает с ним последующие результаты.
it("формирует модель карточки", () => {
const result = createUserCard({
id: 1,
name: "Анна",
role: "admin",
});
expect(result).toMatchSnapshot();
});Небольшой snapshot можно хранить в тесте:
it("формирует краткую информацию", () => {
expect(createSummary({ name: "Анна", active: true }))
.toMatchInlineSnapshot(`
{
"label": "Анна",
"status": "active",
}
`);
});Snapshot удобны для сериализованных структур, результатов генераторов и деревьев компонентов. Они плохо подходят для огромных или нестабильных значений с текущими датами и случайными ID.
Снимок обновляют только после проверки, что изменение ожидаемо. Автоматическое обновление не должно скрывать регрессию. Если результат легко описать несколькими expect, точечные утверждения обычно понятнее snapshot.
Организация тестов в проекте
Рядом с исходным кодом
src/
cart/
calculate-total.js
calculate-total.test.js
users/
normalize-user.js
normalize-user.test.jsТак тест легко найти рядом с модулем.
В отдельном каталоге
src/
cart/calculate-total.js
tests/
unit/cart/calculate-total.test.js
integration/checkout.test.jsТак удобно явно разделять уровни тестирования. Оба подхода допустимы; важнее единообразие проекта.
Частые имена файлов:
module.test.js
module.spec.jsФабрики тестовых данных
function createUser(overrides = {}) {
return {
id: 1,
name: "Анна",
role: "user",
active: true,
...overrides,
};
}
it("запрещает вход неактивному пользователю", () => {
const user = createUser({ active: false });
expect(canSignIn(user)).toBe(false);
});Фабрика уменьшает дублирование и подчёркивает данные, важные для конкретного сценария. Не следует делить одну изменяемую фикстуру между тестами.
Разделение уровней
*.test.js — юнит-тесты
*.integration.test.js — интеграционные тесты
e2e/*.spec.js — end-to-end-тестыЮнит-тесты проверяют модуль изолированно, интеграционные — совместную работу нескольких частей, end-to-end — пользовательский сценарий целиком.
Комплексный пример
Файл calculate-total.js:
export function calculateTotal(items) {
return items.reduce(
(total, item) => total + item.price * item.quantity,
0,
);
}Файл checkout-service.js:
import { calculateTotal } from "./calculate-total.js";
export class CheckoutService {
constructor(paymentGateway) {
this.paymentGateway = paymentGateway;
}
async checkout(items) {
if (items.length === 0) {
throw new Error("Нельзя оформить пустой заказ");
}
const amount = calculateTotal(items);
const payment = await this.paymentGateway.charge(amount);
return { amount, paymentId: payment.id };
}
}Тест чистой функции:
import { describe, expect, it } from "vitest";
import { calculateTotal } from "./calculate-total.js";
describe("calculateTotal", () => {
it("возвращает 0 для пустого заказа", () => {
expect(calculateTotal([])).toBe(0);
});
it("учитывает цену и количество", () => {
const items = [
{ price: 100, quantity: 2 },
{ price: 250, quantity: 1 },
];
expect(calculateTotal(items)).toBe(450);
});
});Тест сервиса с mock-зависимостью:
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CheckoutService } from "./checkout-service.js";
describe("CheckoutService", () => {
let gateway;
let service;
beforeEach(() => {
gateway = { charge: vi.fn() };
service = new CheckoutService(gateway);
});
it("отклоняет пустой заказ", async () => {
await expect(service.checkout([])).rejects.toThrow(
"Нельзя оформить пустой заказ",
);
expect(gateway.charge).not.toHaveBeenCalled();
});
it("списывает рассчитанную сумму", async () => {
gateway.charge.mockResolvedValue({ id: "payment-1" });
const items = [{ price: 100, quantity: 2 }];
await expect(service.checkout(items)).resolves.toEqual({
amount: 200,
paymentId: "payment-1",
});
expect(gateway.charge).toHaveBeenCalledWith(200);
});
it("передаёт ошибку платёжного шлюза", async () => {
const error = new Error("Платёж отклонён");
gateway.charge.mockRejectedValue(error);
await expect(
service.checkout([{ price: 100, quantity: 1 }]),
).rejects.toBe(error);
});
});Здесь вычисление выделено в чистую функцию, внешний шлюз передаётся через конструктор, а успешные и ошибочные сценарии проверяются отдельно.
Частые ошибки
- Тест запускает код, но ничего не утверждает.
- Тесты используют общее изменяемое состояние.
- Юнит-тест обращается к реальной сети или базе данных.
- Асинхронный
expect(...).rejectsне ожидается черезawait. - Проверяется множество внутренних вызовов вместо результата.
- Все зависимости мокируются без необходимости.
- Огромные snapshot обновляются без анализа изменений.
- В репозитории случайно остаётся
it.only(). - Покрытие воспринимается как единственный показатель качества.
Краткий чек-лист
- Название теста описывает ожидаемое поведение.
- Сценарии независимы и детерминированы.
- Есть обычные, граничные и ошибочные случаи.
- Асинхронные проверки ожидаются через
awaitилиreturn. - Моки очищаются, а spy восстанавливаются.
- Внешние зависимости изолированы на границах модуля.
- Проверяется публичный контракт, а не случайные детали реализации.
- Snapshot имеет разумный размер и проверяется вручную.
- Coverage помогает искать пробелы, а не заменяет осмысленные проверки.
- После этапа Green выполняется рефакторинг с сохранением зелёных тестов.
Главная цель тестов — быстрая и надёжная обратная связь о соответствии программы ожидаемому поведению.