Тестирование в 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), потом тесты. Пусть падает как можно раньше и дешевле. Настройте кеш зависимостей — это кратно ускоряет пайплайн.
Источники
- Vitest документация и гайд по миграции с Jest.
- Testing Library — принципы «тестируй как пользователь».
- fast-check — property-based тестирование.
- Testcontainers для Node.
- Kent C. Dodds, Testing Trophy — альтернативный взгляд на баланс тестов для фронтенда.
Что дальше
Умея тестировать, соберём всё в устойчивую продакшн-архитектуру: слои, DI, конфигурация, работа с БД, паттерны надёжности.