Тесты приносят пользу только тогда, когда исполняются автоматически на каждое изменение. Набор, который гоняют вручную и по настроению, не защищает ничего: регрессию замечают уже в проде. Задача CI - сделать проверку неотвратимой: на каждый push и pull request машина прогоняет один и тот же конвейер, и красный результат блокирует слияние. Локальный зелёный - это гипотеза; зелёный в CI - подтверждение на чистой, воспроизводимой машине.
Конвейер выстраивают в понятном порядке, от дешёвого к дорогому. Сначала npm ci - установка строго по lockfile, без своевольного обновления версий. Затем быстрые проверки: lint и typecheck отдельным шагом, потому что тесты не заменяют tsc --noEmit. Дальше сами тесты в CI-режиме, и только в конце build. Такой порядок даёт быстрый отказ: очевидная ошибка линтера валит сборку за секунды, не дожидаясь долгих шагов.
Тесты в CI запускают явным одноразовым прогоном, а не watch. vitest run выполняет набор один раз и выходит с кодом, по которому CI судит об успехе; забытый watch-режим повесил бы job навсегда. Обычно это связывают в скрипт вроде test:ci, добавляя vitest run --coverage, чтобы за один проход получить и результат, и покрытие, и не гонять набор дважды ради отчёта.
Когда набор разрастается, помогает шардирование. Флаг --shard=1/3 разбивает тесты на части, и три параллельные job'ы прогоняют треть каждая, сокращая общее время примерно втрое. Это горизонтальное масштабирование прогона: набор тот же, но исполняется параллельно на нескольких раннерах. Coverage при этом собирают по шардам и сливают в общий отчёт, чтобы дробление не потеряло часть данных.
Воспроизводимость CI держится на фиксации окружения. Версию Node закрепляют явно - той же, что у команды и в проде, - а установка идёт по закоммиченному lockfile, поэтому машина видит ровно те же версии зависимостей. Кэш стора пакетного менеджера ускоряет установку между прогонами, не меняя её результата. Без этой дисциплины 'у меня работает' и 'в CI падает' становятся нормой, а причина - плавающие версии, а не код.
Результаты прогона стоит сохранять как артефакты. Отчёт покрытия и результаты в формате JUnit подключают к job'е, чтобы CI показывал их в интерфейсе: какие тесты упали, как менялось покрытие, где регрессия. Это превращает прогон из бинарного 'прошло/не прошло' в диагностику - по артефактам видно, что именно сломалось, без локального воспроизведения всего пайплайна с нуля.
Отдельная тема - миграция версий Vitest, и главный подводный камень тут в покрытии. При переходе на новую мажорную версию менялся механизм ремаппинга V8-покрытия: та же кодовая база может показать другой процент не потому, что тестов стало меньше, а потому, что покрытие считается точнее. Правильная реакция - проверить, что реально покрыто, а не механически подгонять пороги под новую цифру, пряча просевшие места.
Типичные провалы бьют по воспроизводимости. Запустить в CI vitest вместо vitest run и повесить job в вечном watch. Поставить зависимости через install вместо ci и получить в CI не те версии, что локально. Не зафиксировать Node или таймзону - и ловить падения, которых нет на машине разработчика. И при миграции подкрутить пороги под новый процент вместо того, чтобы разобраться, почему цифра изменилась.
# .github/workflows/ci.yml (фрагмент job)
- uses: actions/setup-node@v4
with: { node-version: '24', cache: 'npm' }
- run: npm ci # строго по lockfile
- run: npm run lint
- run: npm run typecheck # тесты не заменяют tsc --noEmit
- run: npm run test:ci # vitest run --coverage
- run: npm run build# Шардирование: три параллельные job'ы, треть тестов каждая
vitest run --shard=1/3 --coverage
vitest run --shard=2/3 --coverage
vitest run --shard=3/3 --coverage
# coverage сливают по шардам в общий отчёт