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

У теста обычно есть три этапа:

  1. Arrange — подготовка данных и зависимостей.
  2. Act — выполнение проверяемого действия.
  3. 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.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);
  });
});

Основные матчеры

Матчер Назначение
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);
});

Подмена модулей полезна, но сильнее связывает тест со структурой импортов. Явное внедрение зависимости часто проще.

Очистка моков

Не следует мокировать всё подряд. Простые чистые функции лучше проверять непосредственно. Моки особенно полезны для сети, времени, случайности, файлов, хранилищ и внешних сервисов.


Искусственные таймеры

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);
  });
});

Здесь вычисление выделено в чистую функцию, внешний шлюз передаётся через конструктор, а успешные и ошибочные сценарии проверяются отдельно.


Частые ошибки


Краткий чек-лист

Главная цель тестов — быстрая и надёжная обратная связь о соответствии программы ожидаемому поведению.