Тесты пишет каждая команда. Но не каждая пишет такие тесты, которые предотвращают ошибки, переживают рефакторинг и дают уверенность выкатиться в пятницу вечером. Разница между полезными и вредными тестами — не в фреймворке и не в проценте покрытия. Она в стратегии: в понимании того, что тестировать, на каком уровне и какой ценой.
Подход по умолчанию у многих команд — написать модульные тесты на всё и считать дело сделанным. Отчёт о покрытии показывает 90 процентов, сборка зелёная, все довольны — пока изменение в одной функции не ломает три возможности, которых ни один модульный тест не заметил. Проблема не в недостатке дисциплины, а в тестировании не на том уровне. Компонент, связывающий пять сервисов, может иметь стопроцентное модульное покрытие и всё равно упасть в продакшене, потому что взаимодействие этих сервисов никто не проверял.
Это руководство охватывает весь спектр современных стратегий — от быстрых изолированных модульных тестов до медленных, но надёжных сквозных — и объясняет, когда каждая из них даёт пользу, а когда лишь создаёт нагрузку. Цель не в том, чтобы уговорить вас писать больше тестов, а в том, чтобы вы писали тесты, которые оправдывают своё существование: ловят важные ошибки, работают достаточно быстро, чтобы запускаться часто, и переживают неизбежную перестройку кодовой базы.
Модульные тесты: как их писать и что проверять
Модульные тесты — основа большинства стратегий, потому что они быстрые, детерминированные и изолированные. Хороший модульный тест выполняется за миллисекунды, охватывает одно логическое поведение и не зависит от внешних систем вроде баз данных, файловых систем или сетевых интерфейсов. Когда он падает, вы точно знаете, какая часть логики сломалась и почему.
Самая частая ошибка — проверять детали реализации вместо поведения. Проверка того, что функция вызывает определённый внутренний метод или заполняет приватное поле, делает тест хрупким: рефакторинг, сохраняющий внешнее поведение, всё равно его сломает. Тест превращается из страховки в обузу. Проверяйте наблюдаемый результат или побочные эффекты функции, а не то, как она к ним приходит.
Хорошие кандидаты — чистые вспомогательные функции, бизнес-логика без ввода-вывода, проверка и разбор данных, преобразования, алгоритмические вычисления и конечные автоматы. Плохие кандидаты — функции с сетевыми вызовами, компоненты, рисующие интерфейс, и любой код, тесно связанный с внутренностями фреймворка. Их лучше проверять на уровне интеграции или сквозных тестов.
import { describe, it, expect } from "vitest";
import { calculateDiscount, type Order } from "./pricing";
describe("calculateDiscount", () => {
it("returns 0 for orders below the threshold", () => {
const order: Order = {
items: [{ price: 30, quantity: 1 }],
customerSince: new Date("2025-01-01"),
};
expect(calculateDiscount(order)).toBe(0);
});
it("applies 10 percent for orders over $100", () => {
const order: Order = {
items: [
{ price: 80, quantity: 1 },
{ price: 40, quantity: 1 },
],
customerSince: new Date("2025-01-01"),
};
expect(calculateDiscount(order)).toBe(12);
});
it("applies loyalty bonus for customers over 2 years", () => {
const order: Order = {
items: [{ price: 100, quantity: 1 }],
customerSince: new Date("2022-06-01"),
};
expect(calculateDiscount(order)).toBe(15);
});
it("does not apply discount when items are on sale", () => {
const order: Order = {
items: [{ price: 200, quantity: 1, onSale: true }],
customerSince: new Date("2025-01-01"),
};
expect(calculateDiscount(order)).toBe(0);
});
});Пример выше проверяет четыре разных поведения одной функции с разными входами и ожидаемыми результатами. Каждый тест независим, детерминирован и выполняется меньше чем за 10 миллисекунд. Если логика расчёта цены изменится, тесты точно скажут, какие сценарии затронуты. В этом и состоит ценность модульных тестов: быстрая обратная связь по чистой логике.
- Проверяйте поведение, а не реализацию. Смотрите на результаты и побочные эффекты, а не на внутренние вызовы и приватное состояние.
- Одно логическое утверждение на случай. Тест должен падать ровно по одной причине, чтобы сразу было видно, что сломалось.
- Давайте говорящие имена, читающиеся как предложения. «возвращает 0 для заказов ниже порога» лучше, чем «test_discount_low_amount».
- Избегайте общего изменяемого состояния между тестами. Каждый готовит свои данные и убирает за собой.
- Держите значения простыми и осмысленными. Используйте реалистичные данные, похожие на настоящие объекты предметной области.
Интеграционные тесты с настоящими зависимостями
Интеграционные тесты находятся между модульными и сквозными по скорости и охвату. Они проверяют, как несколько частей работают вместе: репозиторий обращается к настоящей базе, сервис — к реальной точке API, компонент отрисовывается с настоящим хранилищем состояния. Ключевое отличие от модульных в том, что они используют настоящие или контейнеризованные зависимости, а не подмены.
Самые ценные интеграционные тесты проверяют, правильно ли ваш код взаимодействует с внешними системами. Модульный тест, подменяющий слой базы данных, скажет, верна ли логика запроса сама по себе, но не скажет, отработает ли настоящий SQL-запрос на настоящей схеме. Интеграционные тесты с Testcontainers или отдельной тестовой базой ловят расхождения схемы, нарушения ограничений, ошибки на границах транзакций и проблемы сериализации, которых модульные не видят вовсе.
Они особенно хороши для слоя репозиториев, обработчиков маршрутов API, потребителей очередей сообщений, миграций и любого кода, который сериализует или разбирает данные на границе. Плата — скорость: тест, поднимающий контейнер Postgres, занимает секунды, а не миллисекунды. Зато ошибки, которые он ловит, обходятся соразмерно дороже, если найти их уже в продакшене.
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { createDatabase, teardownDatabase } from "./test-utils";
import { OrderRepository } from "./order-repository";
describe("OrderRepository", () => {
let db: Awaited<ReturnType<typeof createDatabase>>;
let repo: OrderRepository;
beforeAll(async () => {
db = await createDatabase();
repo = new OrderRepository(db);
});
afterAll(async () => {
await teardownDatabase(db);
});
it("persists and retrieves an order", async () => {
const order = {
id: "ord_001",
customerId: "cus_001",
total: 1250,
status: "pending" as const,
createdAt: new Date("2025-06-01"),
};
await repo.save(order);
const retrieved = await repo.findById("ord_001");
expect(retrieved).toEqual(order);
});
it("returns null for a non-existent order", async () => {
const result = await repo.findById("ord_nonexistent");
expect(result).toBeNull();
});
it("updates order status", async () => {
await repo.save({
id: "ord_002",
customerId: "cus_001",
total: 500,
status: "pending",
createdAt: new Date(),
});
await repo.updateStatus("ord_002", "shipped");
const updated = await repo.findById("ord_002");
expect(updated?.status).toBe("shipped");
});
});Главное правило — держать состояние базы изолированным между запусками. Каждый набор тестов должен создавать свою схему или базу, применять миграции, выполнять тесты и всё сносить. Работа с общей базой порождает нестабильные тесты, где данные одного просачиваются в проверки другого. Используйте Testcontainers, Docker Compose или встроенную поддержку тестовых баз в вашем фреймворке, чтобы каждый запуск начинался с известного состояния.
Один интеграционный тест с настоящей базой стоит сотни модульных, которые её подменяют. Подмена говорит вам, чего вы ждёте от базы. Настоящая база говорит, что она делает на самом деле. После первой же миграции схемы эти две вещи совпадают редко.
Сквозные тесты с Playwright
Сквозные тесты воспроизводят действия настоящего пользователя через весь стек: браузер, фронтенд, API, база данных и внешние сервисы. Это самый медленный и дорогой вид тестов, но именно он ловит самые реалистичные ошибки: сломанную навигацию, отсутствующую проверку формы, неверно показанные ответы API, сбои в потоке аутентификации и различия отрисовки между браузерами.
Playwright стал ведущим инструментом благодаря поддержке разных браузеров, проверкам с автоматическим ожиданием, перехвату сетевых запросов и удобству для разработчика. В отличие от прежних инструментов, требовавших явных ожиданий и повторов, Playwright ждёт, пока элемент действительно станет пригодным для взаимодействия, и тем снимает самую частую причину нестабильности.
import { test, expect } from "@playwright/test";
test("user completes a purchase flow", async ({ page }) => {
await page.goto("/products");
await page.getByRole("link", { name: "Wireless Headphones" }).click();
await page.getByRole("button", { name: "Add to Cart" }).click();
await page.getByRole("button", { name: "View Cart" }).click();
await expect(page.getByText("Wireless Headphones")).toBeVisible();
await expect(page.getByText("$89.99")).toBeVisible();
await page.getByRole("button", { name: "Checkout" }).click();
await page.getByLabel("Email").fill("test@example.com");
await page.getByLabel("Card Number").fill("4242424242424242");
await page.getByLabel("Expiry").fill("12/28");
await page.getByLabel("CVC").fill("123");
await page.getByRole("button", { name: "Pay Now" }).click();
await expect(page.getByText("Order confirmed")).toBeVisible();
await expect(page.getByText("ord_001")).toBeVisible();
});Главная ошибка со сквозными тестами — писать их слишком много. Каждый добавляет минуты к конвейеру сборки, а набор из двухсот легко растягивается на полчаса. Экономика требует избирательности. Сосредоточьтесь на решающих сценариях — регистрация, вход, покупка, поиск, оплата — и оставьте пограничные случаи и состояния ошибок нижним уровням.
- Отдавайте приоритет сценариям, от которых зависит выручка: оформление заказа, переход на более дорогой тариф, обработка платежей.
- Держите сквозные тесты независимыми. Каждый создаёт свои данные и убирает за собой.
- В подготовке используйте вызовы API вместо действий через интерфейс, чтобы быстрее достичь начального состояния.
- Запускайте их на стенде или в среде предпросмотра, а не в продакшене. Видимость тестовых данных контролируйте флагами возможностей.
- Вкладывайтесь в наблюдаемость: скриншоты, трассировки и видеозаписи упавших тестов экономят часы разбирательств.
Разработка через тестирование на практике
Разработка через тестирование — не про тесты, а про проектирование. Цикл «красный, зелёный, рефакторинг» заставляет продумать интерфейс кода до того, как вы его напишете. Написание теста первым требует ответа на вопрос: что эта функция должна принимать и что возвращать? Это ограничение ведёт к лучшим интерфейсам, более слабой связанности и более модульному коду.
Сопротивление обычно рождается из двух источников. Либо человек попробовал подход на задаче, которой он не подходит (код интерфейса, исследовательская работа), либо применял его догматично и больше боролся с инструментом, чем писал полезный код. Это инструмент, а не религия. Лучше всего он работает для алгоритмической логики, преобразований данных, проектирования интерфейсов и любого кода, где договор между входом и выходом ясен заранее.
Практический ход такой: напишите один тест, описывающий следующее нужное поведение. Запустите его и посмотрите, как он падает — это подтверждает, что тест действителен и умеет замечать отсутствие функции. Напишите минимум кода, чтобы он прошёл: самая простая реализация, удовлетворяющая тесту, и есть правильная. Затем улучшайте структуру, не меняя поведения, полагаясь на проходящие тесты как на защиту от регрессий.
Разработка через тестирование не заставляет вас писать больше тестов. Она заставляет писать код лучше. Тест — побочный продукт проектирования, а не цель. Пишете тесты после реализации — вы тестируете. Пишете до — вы проектируете.
Особенно действенно это при исправлении ошибок. Когда об ошибке сообщили, напишите тест, который её воспроизводит (красный), исправьте код (зелёный) и убедитесь, что исправление ничего не сломало (все тесты по-прежнему проходят). Написанный тест становится постоянной защитой от повторения. Со временем набор тестов начинает отражать настоящую историю ошибок проекта, а это куда ценнее набора, созданного ради цифры покрытия.
Тесты по свойствам и фаззинг
Обычные тесты на примерах сверяют конкретные входы с ожидаемыми результатами. Тесты по свойствам устроены иначе: они задают свойство, которое должно выполняться для всех входов, а затем порождают сотни или тысячи случайных входов для проверки. Этот приём ловит пограничные случаи, которые человеку не пришло бы в голову записать как пример.
Например, вместо пяти конкретных случаев для функции сортировки вы описываете свойство: для любого списка сравнимых элементов результат должен содержать те же элементы в неубывающем порядке. Фреймворк порождает случайные списки разного размера, с повторами, пустые и с крайними значениями, и проверяет свойство на каждом.
import { describe, it, expect } from "vitest";
import { faker } from "@faker-js/faker";
describe("sortByPrice", () => {
it("returns items in ascending price order for any input", () => {
for (let i = 0; i < 100; i++) {
const items = Array.from(
{ length: faker.number.int({ min: 0, max: 50 }) },
() => ({
name: faker.commerce.productName(),
price: faker.number.int({ min: 1, max: 1000 }),
})
);
const sorted = sortByPrice(items);
for (let j = 1; j < sorted.length; j++) {
expect(sorted[j - 1].price).toBeLessThanOrEqual(sorted[j].price);
}
}
});
it("preserves all original elements", () => {
for (let i = 0; i < 100; i++) {
const items = Array.from(
{ length: faker.number.int({ min: 0, max: 50 }) },
() => ({
name: faker.commerce.productName(),
price: faker.number.int({ min: 1, max: 1000 }),
})
);
const sorted = sortByPrice(items);
expect(sorted).toHaveLength(items.length);
expect(sorted.map((i) => i.name).sort()).toEqual(
items.map((i) => i.name).sort()
);
}
});
});Fast-check — распространённая библиотека тестов по свойствам для TypeScript, дружащая с Vitest и Jest. В ней есть генераторы для обычных типов, комбинаторы для сложных структур и автоматическое сжатие: найдя падающий случай, библиотека пытается свести его к наименьшему входу, который всё ещё падает, и это заметно облегчает разбор.
Подход особенно ценен для сериализации и разбора, алгоритмов сортировки и фильтрации, правил проверки, переходов конечных автоматов и любых функций с широким пространством входов. Вместе с приёмами фаззинга, подающими искажённые или неожиданные данные, такие тесты вскрывают уязвимости и падения, которые иначе обнаружились бы только в продакшене.
Тесты визуальной регрессии
Функциональные тесты проверяют, что приложение ведёт себя правильно. Тесты визуальной регрессии проверяют, что оно правильно выглядит. Правки CSS, обновления зависимостей и переработанные компоненты могут вносить едва заметные сдвиги вёрстки, расхождения цветов, смену шрифтов или проблемы на точках перелома, которых не увидит ни один функциональный тест. Визуальная регрессия снимает изображения компонентов или страниц, сравнивает их с эталонами и помечает любые расхождения для проверки человеком.
Chromatic, Percy и встроенные снимки Playwright реализуют это на разных уровнях. Chromatic встраивается в Storybook и сравнивает попиксельно, игнорируя сглаживание и субпиксельные различия. Percy предлагает похожее с более широкой поддержкой фреймворков. Проверка toHaveScreenshot в Playwright позволяет писать визуальные утверждения прямо в сквозных тестах, без дополнительных инструментов.
import { test, expect } from "@playwright/test";
test("product page renders correctly", async ({ page }) => {
await page.goto("/products/wireless-headphones");
await expect(page).toHaveScreenshot("product-page.png", {
maxDiffPixelRatio: 0.01,
threshold: 0.2,
fullPage: true,
});
});
test("checkout form is visually stable across states", async ({ page }) => {
await page.goto("/checkout");
await expect(page).toHaveScreenshot("checkout-empty.png");
await page.getByLabel("Email").fill("invalid-email");
await page.getByRole("button", { name: "Continue" }).click();
await expect(page).toHaveScreenshot("checkout-validation-error.png");
});Главная трудность — управление эталонными изображениями. Любое намеренное визуальное изменение требует их обновления, а процесс согласования легко становится узким местом в командах, часто меняющих интерфейс. Решение — считать обновление эталонов частью рабочего процесса: утверждать визуальные различия в том же цикле проверки, что и код, через платформу, встроенную в процесс пул-реквестов.
- Начните с ключевых страниц: главная, оформление заказа, вход, карточка товара и любая страница со сложной вёрсткой.
- Задайте разумные пороги, чтобы игнорировать сглаживание и различия отрисовки шрифтов между операционными системами.
- Используйте снимки на уровне компонентов (через Storybook) для точечного покрытия вместо полностраничных снимков, чувствительных к любой правке.
- Встройте визуальную проверку в процесс пул-реквестов. Требуйте человеческого одобрения для различий на изменённых страницах.
- Запускайте визуальные тесты в единообразной среде (Docker), чтобы исключить различия отрисовки между машинами.
Тестирование серверных компонентов React и Next.js
Серверные компоненты React изменили картину тестирования. Они выполняются только на сервере и не отправляют клиенту ни байта JavaScript. Они могут напрямую обращаться к базам данных, файловой системе и внутренним сервисам, не раскрывая эту логику браузеру. Благодаря этому их можно тестировать иначе, чем клиентские компоненты.
По сути это асинхронные функции, возвращающие JSX. Их можно тестировать как любую асинхронную функцию: вызвать с пропсами, дождаться результата и проверить вывод. Никакой браузерной среды и никаких подмен fetch или обращений к базе, если вы работаете с настоящей тестовой базой. Эта простота — одно из недооценённых достоинств серверных компонентов: подготовка теста заметно легче, чем у клиентских, которым нужен DOM.
import { describe, it, expect } from "vitest";
import { ProductList } from "./product-list";
describe("ProductList (Server Component)", () => {
it("renders products from the database", async () => {
// No mocking needed - the component calls the database directly
const { props } = await ProductList({ category: "electronics" });
expect(props.products.length).toBeGreaterThan(0);
expect(props.products.every((p) => p.category === "electronics")).toBe(
true
);
});
it("shows empty state when no products match", async () => {
const { props } = await ProductList({ category: "nonexistent" });
expect(props.products).toHaveLength(0);
});
});Клиентским компонентам, использующим useState, useEffect, браузерные интерфейсы или обработчики событий, нужна тестовая среда DOM. Vitest предлагает happy-dom или jsdom для имитации браузера. Testing Library даёт средства для поиска в отрисованном выводе и имитации действий пользователя. Отличие от серверных компонентов в том, что здесь нужно отрисовать компонент, дождаться выполнения эффектов и проверить получившееся состояние DOM.
Приложения на Next.js добавляют маршрутизацию, получение данных и серверные действия, которые требуют отдельного внимания. Для страниц с getServerSideProps или generateStaticParams тестируйте функции получения данных отдельно от компонента. Серверные действия тестируйте как обычные асинхронные функции — это они и есть. Для макетов и страниц App Router проверяйте слой данных отдельно от слоя представления, чтобы не привязывать тесты к внутренностям отрисовки фреймворка.
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CheckoutForm } from "./checkout-form";
describe("CheckoutForm", () => {
it("shows validation errors for empty required fields", async () => {
const user = userEvent.setup();
render(<CheckoutForm />);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(screen.getByText("Email is required")).toBeVisible();
expect(screen.getByText("Card number is required")).toBeVisible();
});
it("submits the form with valid data", async () => {
const onSubmit = vi.fn();
const user = userEvent.setup();
render(<CheckoutForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "test@example.com");
await user.type(screen.getByLabelText("Card Number"), "4242424242424242");
await user.type(screen.getByLabelText("Expiry"), "12/28");
await user.type(screen.getByLabelText("CVC"), "123");
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(onSubmit).toHaveBeenCalledWith({
email: "test@example.com",
cardNumber: "4242424242424242",
expiry: "12/28",
cvc: "123",
});
});
});Самое важное различие в новой модели React — разделять тесты серверных и клиентских компонентов. Серверные — это чистые асинхронные функции, тестируйте их без DOM. Клиентским нужна имитация взаимодействия — тестируйте их с Testing Library и userEvent. Смешение двух подходов делает тесты медленнее и сложнее, чем нужно.
Приёмы подмены и когда без них лучше
Подмена зависимостей — одна из самых спорных тем. Подставные объекты заменяют настоящие зависимости управляемыми заменителями и позволяют тестировать код изолированно, не поднимая базы, не обращаясь к внешним интерфейсам и не дожидаясь таймеров. Плата в том, что вы проверяете код против смоделированной версии зависимости, а не против настоящей. Если подмена неточно отражает реальное поведение, тест проходит, а настоящий код падает.
Общее правило — подменять на границе системы. Заменяйте внешние сервисы, которые вы не контролируете (чужие интерфейсы, платёжные системы, почтовые рассылки), и инфраструктуру, дорогую или недетерминированную (базы данных, файловые системы, часы). Не подменяйте внутренние модули, вспомогательные функции и собственные объекты предметной области — их следует проверять с настоящими реализациями в интеграционных тестах.
Vitest предлагает vi.mock, который поднимает объявления подмен в начало файла и заменяет реализации модулей до запуска тестов. Это удобно для переменных окружения, чужих библиотек и глобальных объектов. Риск в том, что подмена на уровне модуля невидима и может породить тесты, которые проходят по отдельности, но падают вместе из-за взаимных помех.
import { describe, it, expect, vi } from "vitest";
import { PaymentService } from "./payment-service";
const mockStripe = {
charges: {
create: vi.fn(),
},
};
vi.mock("stripe", () => ({
default: vi.fn(() => mockStripe),
}));
describe("PaymentService", () => {
it("creates a charge successfully", async () => {
mockStripe.charges.create.mockResolvedValue({
id: "ch_001",
status: "succeeded",
});
const service = new PaymentService();
const result = await service.charge(5000, "tok_visa");
expect(result.status).toBe("succeeded");
expect(mockStripe.charges.create).toHaveBeenCalledWith({
amount: 5000,
currency: "usd",
source: "tok_visa",
});
});
it("throws on failed payment", async () => {
mockStripe.charges.create.mockRejectedValue(
new Error("card_declined")
);
const service = new PaymentService();
await expect(service.charge(5000, "tok_visa")).rejects.toThrow(
"card_declined"
);
});
});Надёжнее подмен оказываются тестовые дублёры, реализующие тот же интерфейс, что и настоящая зависимость, но упрощённо и в памяти. Вместо подмены модуля базы данных напишите версию репозитория, хранящую данные в Map. Такой дублёр остаётся лёгким, но честным, и проходит те же участки кода, что и настоящая база, без затрат на её подготовку.
Каждая подмена создаёт зазор между вашими тестами и действительностью. Именно в этом зазоре прячутся ошибки. Прежде чем тянуться к подмене, спросите: могу ли я проверить это с настоящей зависимостью в управляемой среде? Если да — берите настоящую. Если нет (слишком медленно, слишком дорого, недетерминированно) — подменяйте, но узко, на границе системы.
- Подменяйте на границах: чужие интерфейсы, библиотеки поставщиков, платёжные шлюзы, внешние сервисы, которые нельзя запустить локально.
- Для собственных абстракций (репозитории, кеши, очереди) используйте дублёры в памяти, а не подмены.
- Не подменяйте то, что вам не принадлежит: подмена внутренностей библиотеки привязывает тесты к деталям реализации.
- Предпочитайте внедрение зависимостей подмене на уровне модуля. Явная передача делает подготовку теста прозрачной.
- Ограничивайте область действия подмены тем тестом, которому она нужна. Сбрасывайте все подмены между тестами.
Тестирование в конвейерах CI/CD
Набор тестов ценен только тогда, когда он запускается стабильно и быстро даёт ответ. Медленные, нестабильные тесты или те, что бегают лишь на машинах разработчиков, — обуза. Встраивание в конвейер CI/CD обеспечивает их запуск на каждом пул-реквесте, при каждом слиянии в основную ветку и, для критических путей, перед каждой выкладкой.
Пирамида естественно переводится в стадии конвейера. Модульные тесты идут первыми, на каждый пуш, потому что они быстрые и ловят простые ошибки логики. Дальше — интеграционные, по возможности параллельно, с контейнеризованными зависимостями. Сквозные идут последними, только на пул-реквестах и перед выкладками, поскольку медленны и дороги. Визуальные тесты работают рядом со сквозными, сравнивая снимки с эталоном основной ветки.
Время выполнения важно. Набор, идущий 45 минут, подталкивает разработчиков вовсе не запускать тесты локально и отправлять сломанный код. Что помогает: выполнять только тесты, затронутые текущим изменением, распределять наборы по параллельным исполнителям, кешировать node_modules и браузеры Playwright между запусками, и ставить медленные сквозные тесты по расписанию, а не на каждый коммит.
# .github/workflows/test.yml - example pipeline structure
name: test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npx vitest run --project unit
integration:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: test
POSTGRES_PASSWORD: test
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx vitest run --project integration
e2e:
needs: [unit, integration]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install --with-deps
- run: npx playwright testНестабильные тесты — главная угроза доверию к конвейеру. Тест, который время от времени падает без связи с изменениями кода, подтачивает доверие ко всему построению. Команда начинает не замечать падения, сливать код с красной сборкой, и в итоге набор превращается в шум. Вкладывайтесь в их выявление: помечайте, изолируйте и в первую очередь чините или удаляйте. Тест, которому нельзя доверять, хуже отсутствия теста, потому что приучает игнорировать падения.
Отчётность о результатах — ещё одна недооценённая практика. Вывод вида «3 теста упали» без подробностей заставляет листать журналы в поисках самого сообщения об ошибке. Хороший отчёт показывает упавшую проверку, ожидаемое и фактическое значения, файл и номер строки, а также нужный контекст: снимки экрана, стек вызовов. HTML-отчёты Vitest и Playwright или примечания GitHub Actions делают результаты видимыми прямо в процессе пул-реквеста.
Спор о трофее и пирамиде
Классическая пирамида — модульные тесты в основании, интеграционные посередине, сквозные наверху — направляет стратегию тестирования уже два десятилетия. Она несёт простую мысль: много быстрых изолированных тестов, меньше более медленных интеграционных и совсем мало медленных сквозных. Модель хорошо ложится на серверные сервисы, где единицей выкладки является функция или класс с чёткими границами входа и выхода.
В качестве альтернативы был предложен трофей тестирования, лучше отражающий современную фронтенд-разработку. Он превращает пирамиду в ромб, где интеграционные тесты образуют самый широкий слой, а модульных и сквозных меньше. Довод в том, что для фронтенда наибольшая польза идёт от проверки того, как компоненты работают вместе — а это и есть интеграционные тесты, — а не от проверки отдельных функций или полных пользовательских путей.
Трофей отражает практическую истину: самые ценные тесты приложения на React — те, что отрисовывают компонент, взаимодействуют с ним и проверяют результат. Такие тесты задействуют сразу компонент, его детей, его хуки, управление состоянием и вызовы API. Они быстрее сквозных и реалистичнее модульных. Набор хороших интеграционных тестов даёт во фронтенде больше уверенности на каждый тест, чем модульные или сквозные.
На деле обе модели упрощают более сложную правду. Верная стратегия зависит от вашей архитектуры. Серверный микросервис с тяжёлой бизнес-логикой выигрывает от пирамиды: глубокое модульное покрытие предметной логики, меньше интеграционных тестов для слоя API и несколько контрактных для внешних сервисов. Фронтенд со сложными взаимодействиями выигрывает от трофея: широкое интеграционное покрытие компонентов и страниц, точечные модульные тесты для вспомогательной логики и сквозные для критических путей.
И пирамида, и трофей неверны, если следовать им догматично. Верная стратегия тестирования — та, что ловит именно ваши ошибки, работает достаточно быстро для вашей команды и переживает вашу архитектуру. Она может выглядеть как пирамида, как трофей или как фигура, которой ещё не придумали названия. Перестаньте спорить о форме и посмотрите, что ваши тесты действительно ловят.
Важная мысль за обеими моделями в том, что уровни имеют разное соотношение затрат и пользы. Модульный тест стоит миллисекунд написания и выполнения. Интеграционный — секунд. Сквозной — минут. Распределяйте бюджет тестирования пропорционально пользе, которую каждый уровень приносит именно вашему приложению. Если это насыщенная данными панель со сложной бизнес-логикой — вкладывайтесь в модульные и интеграционные. Если это контентный сайт с малой интерактивностью — сквозные тесты для ключевых сценариев и визуальная регрессия для вёрстки окажутся самым выгодным вложением.
Как строится культура тестирования
Лучшая стратегия проваливается, если команда не верит в тесты. Культура тестирования не создаётся предписанными порогами покрытия или обязательным TDD. Она возникает там, где писать тесты — естественный путь наименьшего сопротивления. Тесты пишут, когда их легко писать, быстро запускать и когда их польза очевидна. Когда они хрупкие, медленные и полны ложных падений, разработчики находят способы их обойти.
Начните с того, чтобы сделать работу с тестами приятной. Вложитесь в инфраструктуру: быстрые исполнители, надёжные тестовые базы, выявление нестабильных тестов. Напишите вспомогательные средства, делающие обычные проверки короткими. Опишите приёмы в командном руководстве, чтобы каждый знал, как проверить новую точку API, компонент React или миграцию, не начиная с нуля. Один раз построить хорошую инфраструктуру дешевле, чем каждый день бороться с плохими тестами.
Проверка кода должна включать проверку тестов. Рецензенту стоит спросить: действительно ли этот тест покрывает поведение, которое заявляет? Прошёл бы он и при неверной реализации? Проверяет ли он нужное на нужном уровне? Читаем ли он и поддерживаем? Относитесь к тестам как к полноценному коду — с теми же требованиями к проверке, стилю и качеству, что и к продакшен-коду.
Метрики покрытия — хороший диагностический инструмент и скверная цель. Поставьте планку в 90 процентов — и получите 90 процентов, а не лучшие тесты. Разработчики напишут тривиальные тесты на геттеры и пустые конструкторы, чтобы дотянуться до цифры, ничего не добавив к страховке. Используйте отчёты, чтобы находить непроверенные участки, а не чтобы навязывать произвольные пороги. Семьдесят процентов с хорошо расставленными интеграционными тестами ценнее девяноста пяти с поверхностными модульными.
- Сделайте написание тестов лёгким: вспомогательные средства, фабрики и помощники, сокращающие повторяющийся код в каждом файле.
- Отмечайте улучшения в тестах так же, как выпуск возможностей. Тест, устраняющий целый класс ошибок, — это возможность.
- Передавайте ответственность за тестовую инфраструктуру по кругу, чтобы знание распределялось, а не оседало у одного человека.
- Регулярно разбирайте нестабильные тесты. Следите за их количеством как за показателем здоровья команды и чините в первую очередь.
- Напишите командный манифест тестирования: что проверять на каждом уровне, что не проверять и какие компромиссы приняты.
Конечная цель культуры тестирования — не число покрытия и не идеальный конвейер. Это уверенность. Уверенность, что рефакторинг не сломает продакшен. Уверенность, что выкатиться в пятницу вечером безопасно. Уверенность, что найденная ошибка после исправления останется исправленной. Стратегия, которая даёт команде такую уверенность, — хорошая стратегия, как бы она ни выглядела: пирамидой, трофеем или чем-то посередине.
Собираем всё вместе
Построение действенной стратегии — не выбор одного подхода вместо другого, а понимание компромиссов и применение подходящего инструмента к каждой ситуации. Модульные тесты ловят ошибки логики за миллисекунды. Интеграционные ловят ошибки взаимодействия с настоящими зависимостями. Сквозные ловят сбои, видимые пользователю, по всему стеку. Тесты по свойствам ловят пограничные случаи, о существовании которых вы не подозревали. Визуальная регрессия ловит непреднамеренные изменения внешнего вида.
Команды, выпускающие надёжно, — не те, у кого самые высокие цифры покрытия. Это команды, которые вдумчиво разобрались, что даёт каждый уровень, привели вложения в соответствие со своим профилем риска и вырастили культуру, где тесты пишут потому, что они приносят пользу, а не потому, что этого требует регламент. Начните с ревизии имеющегося набора. Для каждого теста спросите: оправдывает ли он своё существование? Если ответ не однозначное «да», удалите его или замените. Лучший набор тестов — не самый большой, а тот, что даёт больше всего уверенности на минуту выполнения.