TypeScript Тестирование в TypeScript: пирамида, Vitest, моки и CI
0%

Тестирование в TypeScript: пирамида, Vitest, моки и CI

Тестирование в TypeScript: пирамида, Vitest, моки и CI

Типы ловят целый класс ошибок ещё до запуска — но они доказывают только согласованность форм, а не корректность поведения. «Функция принимает число и возвращает число» ничего не говорит о том, правильное ли это число. Тесты проверяют поведение. В типизированном проекте эти две сети — типы и тесты — дополняют друг друга: чем сильнее типы, тем меньше тривиальных тестов приходится писать, и тем больше времени остаётся на тесты бизнес-логики.

Тест-пирамида

Классическая модель баланса тестов (Майк Кон):

  • Юнит-тесты — проверяют одну функцию/модуль в изоляции. Их должно быть большинство: миллисекунды на прогон, нет внешних зависимостей.
  • Интеграционные — проверяют совместную работу нескольких частей: сервис + реальная БД (в контейнере), HTTP-роут целиком. Медленнее, но ловят то, что юниты с моками пропускают.
  • E2E — прогоняют весь сценарий через реальный интерфейс (Playwright для UI, реальные HTTP-вызовы для API). Их мало: они медленные и хрупкие, но дают уверенность в главных пользовательских путях.

Антипаттерн «мороженое-рожок» (перевёрнутая пирамида — много медленных e2e, мало юнитов) делает CI мучительно долгим и нестабильным. Держите основание широким.

Раннер: Vitest или Jest

Два основных инструмента:

  • Vitest — современный выбор для новых проектов. Нативно понимает TypeScript и ESM (через Vite/esbuild), быстрый, hot-reload watch, API совместим с Jest. Для TS-проекта это путь наименьшего сопротивления.
  • Jest — исторический стандарт, огромная экосистема. Для TS требует настройки: ts-jest (проверяет типы при прогоне, но медленнее) или @swc/jest/babel-jest (быстро стирают типы без проверки). Актуален в больших легаси-кодовых базах и в связке с Next.js.

Ключевой trade-off тот же, что при сборке: ts-jest даёт проверку типов в тестах ценой скорости; трансформеры на swc/esbuild быстрее, но типы проверяет отдельный tsc --noEmit. В проде обычно берут второй вариант + отдельный typecheck-шаг.

Дальше примеры на Vitest (для Jest они почти идентичны).

pnpm add -D vitest @vitest/coverage-v8
// sum.ts
export function sum(nums: readonly number[]): number {
  return nums.reduce((acc, n) => acc + n, 0);
}
// sum.test.ts
import { describe, it, expect } from "vitest";
import { sum } from "./sum.js";

describe("sum", () => {
  it("складывает числа", () => {
    expect(sum([1, 2, 3])).toBe(6);
  });

  it("возвращает 0 для пустого массива (граничный случай)", () => {
    expect(sum([])).toBe(0);
  });

  it("работает с отрицательными", () => {
    expect(sum([-1, 1])).toBe(0);
  });
});

Структура хорошего теста — AAA (Arrange–Act–Assert): подготовить данные, выполнить действие, проверить результат. Один тест проверяет одно поведение. Имя теста описывает поведение, а не реализацию.

Асинхронные тесты

Async-код тестируется прозрачно через async/await:

import { describe, it, expect } from "vitest";

it("парсит валидного пользователя", async () => {
  const user = await parseUser({ id: crypto.randomUUID(), email: "a@b.co" });
  expect(user.email).toBe("a@b.co");
});

it("бросает на невалидном вводе", async () => {
  // проверяем именно факт отклонения промиса
  await expect(parseUser({ email: 123 })).rejects.toThrow();
});

Правило: всегда await асс-выражение с .rejects/.resolves, иначе тест завершится до проверки (и «пройдёт» ложно). Это тот же floating-promise-капкан.

Моки, стабы и подмена зависимостей

Юнит-тест изолирует код от внешнего мира: сеть, БД, часы, случайность. Их подменяют дублёрами:

  • Stub — возвращает заранее заданный ответ.
  • Mock — то же плюс запоминает, как его вызывали (для проверки взаимодействий).
  • Fake — упрощённая рабочая реализация (in-memory-репозиторий вместо БД).
  • Spy — оборачивает реальную функцию, наблюдая за вызовами.
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

describe("отправка приветствия", () => {
  it("вызывает почтовый шлюз ровно один раз", async () => {
    // мок-функция с типизированной сигнатурой
    const sendMail = vi.fn<(to: string, body: string) => Promise<void>>()
      .mockResolvedValue(undefined);

    await welcomeUser({ email: "u@example.com" }, sendMail);

    expect(sendMail).toHaveBeenCalledOnce();
    expect(sendMail).toHaveBeenCalledWith("u@example.com", expect.any(String));
  });

  it("детерминирует время через фейковые таймеры", () => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
    expect(makeTimestamp()).toBe("2026-01-01T00:00:00.000Z");
    vi.useRealTimers();
  });
});

Важный принцип: мокайте по минимуму. Чем больше моков, тем меньше тест похож на реальность и тем легче он «зеленеет», проверяя фикцию. Лучшая архитектура (dependency injection — передача зависимостей аргументом/через конструктор, см. файл про архитектуру) делает подмену тривиальной без моканья модулей. Мокайте границы (внешние API, платежи), а внутреннюю логику тестируйте по-настоящему.

Интеграционные тесты с реальной БД

Мок БД проверяет ваши предположения о БД, а не саму БД. Интеграционные тесты поднимают настоящую базу в контейнере. Инструмент — Testcontainers:

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";

describe("UserRepository (интеграция)", () => {
  let container: StartedPostgreSqlContainer;

  beforeAll(async () => {
    // поднимаем настоящий Postgres в Docker — эфемерный, изолированный
    container = await new PostgreSqlContainer("postgres:16").start();
    await runMigrations(container.getConnectionUri());
  }, 60_000); // таймаут: скачивание образа занимает время

  afterAll(async () => {
    await container.stop();
  });

  it("сохраняет и читает пользователя", async () => {
    const repo = new UserRepository(container.getConnectionUri());
    const created = await repo.create({ email: "int@test.io" });
    const found = await repo.findById(created.id);
    expect(found?.email).toBe("int@test.io");
  });
});

Каждый тест должен оставлять базу в известном состоянии (транзакция с откатом или очистка таблиц), чтобы тесты не влияли друг на друга.

Property-based тестирование

Обычный тест проверяет конкретные примеры. Property-based тест проверяет свойство на сотнях случайно сгенерированных входов и, найдя контрпример, автоматически «сжимает» его до минимального. Инструмент — fast-check:

import { it, expect } from "vitest";
import fc from "fast-check";

it("сериализация обратима: parse(stringify(x)) === x", () => {
  fc.assert(
    fc.property(
      fc.record({ id: fc.uuid(), name: fc.string() }), // генератор входов
      (user) => {
        // свойство должно выполняться для ЛЮБОГО user
        expect(JSON.parse(JSON.stringify(user))).toEqual(user);
      },
    ),
  );
});

Property-based тесты особенно хороши для парсеров, кодеков, математики, инвариантов структур данных — там, где важно «для всех входов», а не для трёх примеров.

Тестирование самих типов

Иногда нужно проверить, что тип устроен как надо (важно для библиотек и сложной типовой логики). Для этого есть инструменты статических утверждений:

import { expectTypeOf } from "vitest";

it("вывод типа корректен", () => {
  expectTypeOf(sum([1, 2])).toEqualTypeOf<number>();
  expectTypeOf(parseUser).parameter(0).toEqualTypeOf<unknown>();
});

Также применяют tsd, expect-type или встроенные type-only утверждения. Эти проверки исполняются на этапе typecheck, а не в рантайме.

Тестирование UI (кратко)

Для компонентов (React/Vue/Svelte) стандарт — Testing Library. Её философия: тестировать так, как взаимодействует пользователь (по тексту и ролям), а не по деталям реализации (классам, стейту).

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

it("показывает приветствие после клика", async () => {
  render(<Greeter name="Аня" />);
  await userEvent.click(screen.getByRole("button", { name: /поздороваться/i }));
  expect(screen.getByText(/привет, аня/i)).toBeVisible();
});

Покрытие: полезно, но не самоцель

Покрытие (--coverage) показывает, какие строки исполнялись тестами. Это детектор дыр, а не мера качества: 100% покрытие бессмысленными тестами хуже, чем 70% осмысленными. Используйте покрытие, чтобы найти непротестированные критичные пути, но не гонитесь за цифрой. Разумный порог-гейт в CI — 70–85% на бизнес-логику.

// vitest.config.ts (фрагмент) — гейт по покрытию
{
  "test": {
    "coverage": {
      "provider": "v8",
      "thresholds": { "lines": 80, "functions": 80, "branches": 75 }
    }
  }
}

Встраивание в CI

Тесты приносят пользу, только если гоняются на каждый PR автоматически. Полный гейт качества для TypeScript-проекта:

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm
      - run: pnpm install --frozen-lockfile   # воспроизводимо
      - run: pnpm typecheck                    # tsc --noEmit: гейт типов
      - run: pnpm lint                         # ESLint
      - run: pnpm test -- --coverage           # тесты + покрытие

Порядок неслучаен: сначала быстрые и дешёвые проверки (typecheck, lint), потом тесты. Пусть падает как можно раньше и дешевле. Настройте кеш зависимостей — это кратно ускоряет пайплайн.

Источники

Что дальше

Умея тестировать, соберём всё в устойчивую продакшн-архитектуру: слои, DI, конфигурация, работа с БД, паттерны надёжности.

Архитектура и продакшн

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов