Тестирование ПО: unit, интеграционные и E2E-тесты, CI/CD и метрики качества

Как построить стратегию автотестов: пирамида тестирования, Vitest и Jest, интеграционные тесты с Testcontainers, E2E на Playwright, нагрузка в k6, тесты в GitHub Actions, метрики и план внедрения за три месяца.

Ошибка в продакшене стоит дороже, чем кажется в момент релиза. Это ночь экстренных правок, штраф по SLA, разговор с клиентом, который перестаёт доверять следующей версии. Консорциум CISQ в отчёте 2022 года оценил совокупные потери экономики США от некачественного ПО в 2,41 трлн долларов — цифра масштабная, но суть понятна любой команде: дефект, который прошёл до пользователя, обходится дороже дефекта, пойманного на ноутбуке разработчика. Автоматизированное тестирование — главный способ ловить их раньше. В этом материале — как устроить его по уровням, какими инструментами пользоваться сегодня, что измерять и в каком порядке внедрять тесты в проект, где их пока нет. Пирамида тестирования: как распределять тесты по уровням Короткий ответ: много быстрых модульных тестов в основании, меньше интеграционных в середине и немного сквозных сценариев на вершине. Модель описал Майк Кон в книге «Succeeding with Agile» (2009): у него было три слоя — unit, сервисные и UI-тесты. Логика простая: чем выше уровень, тем тест медленнее, дороже в поддержке и хуже указывает на место поломки. Каждый уровень отвечает на свой вопрос: Модульные тесты — работает ли функция или класс в изоляции? Интеграционные тесты — правильно ли компоненты работают вместе: сервис с базой, API с очередью, приложение с внешним сервисом? Сквозные (E2E) тесты — проходит ли пользователь ключевой сценарий целиком? Часто цитируемое соотношение 70/20/10 (unit / интеграционные / E2E) — не закон, а ориентир. Его, например, предлагал Майк Вакер в статье «Just Say No to More End-to-End Tests» в блоге Google Testing (2015). Для фронтенд-приложений популярна альтернатива — «тестовый трофей» Кента Доддса, где основной вес смещён на интеграционные тесты. Обе модели сходятся в главном: сквозных тестов должно быть мало. Перевёрнутую пирамиду Алистер Скотт назвал «рожком мороженого». Это типичная картина в командах, которые начинали с ручного тестирования и затем автоматизировали именно его — клики по интерфейсу. Итог предсказуем: прогон идёт десятки минут, тесты падают по посторонним причинам, и разработчики перестают им верить. Модульные тесты: фундамент, который окупается первым Модульный тест проверяет наименьшую единицу кода — функцию, метод, класс — без реальной базы данных, сети и файловой системы. Внешние зависимости заменяются заглушками. Такие тесты выполняются за миллисекунды, поэтому их можно запускать при каждом сохранении файла. Хороший модульный тест соответствует принципам FIRST: Fast — быстрый; Independent — не зависит от других тестов и порядка запуска; Repeatable — даёт одинаковый результат в любом окружении; Self-validating — результат только «прошёл / не прошёл», без ручной интерпретации; Timely — пишется вместе с кодом, а не «когда-нибудь потом». Какой инструмент выбрать: Vitest или Jest В экосистеме JavaScript и TypeScript два основных варианта. Jest — давний стандарт с огромной базой примеров, он по-прежнему развивается. Vitest построен вокруг Vite, из коробки понимает TypeScript и ES-модули, совместим с большей частью API Jest и обычно проще в настройке для проектов на Vite, Nuxt, SvelteKit и других современных сборщиках. Для нового фронтенд-проекта разумный выбор по умолчанию — Vitest; переводить большой рабочий набор тестов с Jest без явной причины не обязательно. Пример: функция расчёта скидки и тесты к ней на Vitest. // discount.ts export function calculateDiscount(price: number, percent: number): number { if (percent 0 || percent 100) { throw new RangeError('Процент скидки должен быть от 0 до 100'); } return Math.round(price * (1 - percent / 100)); } // discount.test.ts import { describe, it, expect } from 'vitest'; import { calculateDiscount } from './discount'; describe('calculateDiscount', () = { it('применяет скидку', () = { expect(calculateDiscount(10000, 20)).toBe(8000); }); it.each([ [10000, 0, 10000], [10000, 100, 0], [999, 10, 899], ])('цена %i со скидкой %i%% даёт %i', (price, percent, expected) = { expect(calculateDiscount(price, percent)).toBe(expected); }); it('отклоняет процент вне диапазона', () = { expect(() = calculateDiscount(10000, 150)).toThrow(RangeError); expect(() = calculateDiscount(10000, -10)).toThrow(RangeError); }); }); Обратите внимание на it.each : параметризованный тест проверяет граничные значения — ноль, сто процентов, округление — без копирования однотипного кода. Сколько покрытия достаточно Прямой ответ: столько, сколько закрывает рискованную бизнес-логику, а не «как можно больше». В руководстве Google по покрытию кода (Google Testing Blog, 2020) приводятся общие ориентиры — 60% «приемлемо», 75% «достойно», 90% «образцово», — но там же подчёркивается, что единого порога сверху компания не навязывает, а рост с 90% до 95% почти ничего не даёт. Важнее смотреть, какие именно строки и ветки остались непокрытыми и насколько опасны эти пробелы. Практические правила: Тестируйте поведение, а не реализацию. Если тест ломается при рефакторинге, который не менял внешний контракт, — тест написан неудачно. Одна проверяемая идея на тест. Несколько утверждений допустимы, если они описывают один сценарий. Приватные методы проверяйте через публичный интерфейс. Если хочется проверить качество самих тестов, попробуйте мутационное тестирование (например, Stryker): инструмент вносит в код мелкие искажения и показывает, какие из них тесты не заметили. Модульные тесты хорошо пишутся к коду, который хорошо устроен: маленькие функции, явные зависимости, отсутствие скрытых побочных эффектов. Об этом подробно — в статье о чистом коде . Интеграционные тесты: где ломаются стыки Интеграционный тест проверяет, что модули корректно работают вместе: контроллер с сервисом и базой, сервис с брокером сообщений, клиент с внешним API. Он медленнее модульного — от сотен миллисекунд до секунд, — зато ловит ошибки, которые в изоляции не видны: неверный SQL, несовпадение формата данных, забытую миграцию. Пример теста HTTP API на Node.js с библиотекой supertest: // users.integration.test.ts import request from 'supertest'; import { beforeAll, afterAll, describe, it, expect } from 'vitest'; import { app } from '../app'; import { db } from '../database'; beforeAll(async () = { await db.migrate.latest(); }); afterAll(async () = { await db.destroy(); }); describe('POST /api/users', () = { it('создаёт пользователя и возвращает 201', async () = { const response = await request(app) .post('/api/users') .send({ email: 'new@example.com', name: 'Иван Петров' }); expect(response.status).toBe(201); expect(response.body).toMatchObject({ id: expect.any(Number), email: 'new@example.com', }); }); it('возвращает 409 при повторном email', async () = { const payload = { email: 'duplicate@example.com', name: 'Первый' }; await request(app).post('/api/users').send(payload); const response = await request(app).post('/api/users').send(payload); expect(response.status).toBe(409); }); }); Главный вопрос интеграционных тестов — изоляция данных. Рабочие подходы: Testcontainers. Библиотека поднимает настоящую базу, Redis или Kafka в Docker-контейнере на время прогона. Есть реализации для Java, Node.js, Python, Go, .NET и других языков. Тест работает с той же СУБД, что и продакшен, а не с её имитацией. Транзакционная изоляция. Каждый тест выполняется внутри транзакции, которая откатывается в конце. Быстро, но не подходит для проверки логики, которая сама управляет транзакциями. Контрактное тестирование. Для микросервисов: потребитель и поставщик API фиксируют договорённость о формате запросов и ответов и проверяют её независимо друг от друга. Инструменты — Pact, Spring Cloud Contract. E2E-тесты: только критичные пользовательские пути Сквозной тест воспроизводит действия пользователя в настоящем браузере и проходит всю цепочку: интерфейс, бэкенд, база, внешние сервисы. Это самый дорогой и самый хрупкий вид тестов, поэтому их пишут только для сценариев, поломка которых бьёт по деньгам: регистрация, вход, корзина, оплата, отправка заявки. Для веб-приложений основной инструмент сегодня — Playwright. Он работает с движками Chromium, Firefox и WebKit, запускается в CI без графической оболочки, сам дожидается готовности элементов и сохраняет трассировку упавшего теста — с шагами, снимками DOM и сетевыми запросами. Cypress остаётся рабочей альтернативой, особенно в командах, которые уже им пользуются. // checkout.spec.ts import { test, expect } from '@playwright/test'; test.describe('Оформление заказа', () = { test('пользователь добавляет товар и оформляет заказ', async ({ page }) = { await page.goto('/catalog'); await page.getByTestId('product-card').first().click(); await page.getByRole('button', { name: 'Добавить в корзину' }).click(); await expect(page.getByTestId('cart-counter')).toHaveText('1'); await page.getByRole('link', { name: 'Корзина' }).click(); await page.getByRole('button', { name: 'Оформить заказ' }).click(); await page.getByLabel('Email').fill('buyer@company.ru'); await page.getByLabel('Телефон').fill('+7 999 123-45-67'); await page.getByRole('button', { name: 'Подтвердить' }).click(); await expect(page.getByRole('heading', { name: 'Заказ принят' })).toBeVisible(); await expect(page.getByTestId('order-number')).toHaveText(/^#\d+$/); }); }); Правила устойчивых E2E-тестов: Ищите элементы по роли и подписи ( getByRole , getByLabel ) или по специальному атрибуту data-testid . CSS-классы меняются при любом редизайне. Проверяйте бизнес-результат : заказ создан, письмо ушло, — а не внутреннюю структуру страницы. Готовьте данные через API , а не через интерфейс. Каждый тест создаёт свои данные и не зависит от соседей. Никаких фиксированных пауз. Вместо «подождать три секунды» — ожидание конкретного состояния. Запускайте параллельно. Playwright распределяет тесты по воркерам и между машинами CI (шардирование), и время прогона сокращается почти пропорционально их числу. Нагрузочное тестирование: выдержит ли система пик Функциональные тесты не отвечают на вопрос, что будет в распродажу, в день рассылки или при запуске рекламы. Для этого нужны нагрузочные тесты. Основные виды: Load — ожидаемая нагрузка: укладывается ли время ответа в заданный порог. Stress — нагрузка выше расчётной: где точка отказа и как система деградирует. Spike — резкий скачок: важно для e-commerce и медиа. Soak — нормальная нагрузка в течение многих часов: выявляет утечки памяти и накопление соединений. Удобный инструмент — Grafana k6: сценарии пишутся на JavaScript (с версии 1.0, вышедшей в мае 2025 года, поддерживается и TypeScript), пороги качества задаются прямо в скрипте, и прогон можно встроить в CI. // load-test.js import http from 'k6/http'; import { check, sleep } from 'k6'; export const options = { stages: [ { duration: '2m', target: 100 }, // разгон до 100 виртуальных пользователей { duration: '5m', target: 100 }, // удержание { duration: '2m', target: 200 }, // пик { duration: '1m', target: 0 }, // снижение ], thresholds: { http_req_duration: ['p(95) 500'], // 95% запросов быстрее 500 мс http_req_failed: ['rate 0.01'], // ошибок меньше 1% }, }; export default function () { const res = http.get('https://staging.example.ru/api/products'); check(res, { 'статус 200': (r) = r.status === 200 }); sleep(1); } Нагрузочные тесты гоняют на стенде, максимально похожем на продакшен, а не на рабочей системе в часы пик. А понять, что именно тормозит под нагрузкой, помогают метрики и трассировка — об этом в статье о мониторинге и observability . Тесты в CI/CD: как встроить проверки в процесс Тесты, которые запускают вручную «перед релизом», быстро перестают запускать. Работает только автоматический запуск в конвейере, где красный результат блокирует слияние и выкладку. Типовая структура: Pre-commit. Линтер и форматирование только изменённых файлов — секунды (husky и lint-staged). Pull request. Модульные и интеграционные тесты, проверка типов. Цель — несколько минут; падение блокирует слияние. Перед выкладкой на стенд. Сборка и E2E-сценарии. После выкладки в продакшен. Короткие smoke-тесты критичных путей; при падении — откат. Пример конфигурации GitHub Actions для Node.js-пр