
Привет! Я Максим Кузьмин, старший инженер по автоматизации в команде Т-Путешествий. Строю и развиваю процессы автоматизации и разрабатываю инструменты тестирования.
Для внутренних нужд мы разработали фреймворк для изолированного тестирования бэкенда. Он написан на TypeScript, обеспечивает гибкость, масштабируемость и интеграцию с разными внутренними системами. Выступает как единое решение для написания, запуска и поддержки тестов в стабильной и предсказуемой среде.
В статье будет история миграции с Jest на Vitest. Расскажу, какие проблемы подтолкнули нас к переходу, как мы адаптировали окружение и какие результаты получили. Поделюсь опытом улучшения скорости запуска тестов и стабильности результатов. Надеюсь, что наш опыт поможет кому-то превратить автотесты из источника проблем в устойчивый инструмент контроля качества.
Как проблемы backend-тестирования подтолкнули нас к созданию собственного инструмента
В нашей команде бэкенд реализован на двух относительно редких стеках: крупный монорепозиторий на Haskell и около десятка микросервисов на Scala. Использование не самых распространенных языков осложняло поддержку и развитие тестовой инфраструктуры. Разнородность кодовой базы дополнительно усложняла унификацию процессов.
У нас было около 3600 сценариев автотестов, в основном end-to-end, второго слоя пирамиды почти не было. Упавшие автотесты хранились в отдельном репозитории и запускались вне основного GitLab CI разработки и не блокировали его. В CI/CD не было встроенного запуска автотестов, они не запускались автоматически при изменении.
Е2Е-тесты требуют запуска в среде с поднятой инфраструктурой. Преобладание Е2Е, отсутствие автоматического запуска, внешнее расположение тестов и недостаточная интеграция в основной CI/CD-пайплайн разработки привели к тому, что полный прогон тестов занимал около дня.

Еще хуже ситуация была со стабильностью тестов: успешны лишь около 40% проверок, и команде QA регулярно приходилось разбирать и актуализировать сотни падающих тестов. Причины падений были разные: то недоступен стенд сервиса получения тестового клиента, то не работает мокирование, потому что упал внешний мокер, то еще какие-то внутренние сервисы не отвечают. Со временем команда QA просто устала тратить столько сил из-за нестабильности тестовой среды.
Мы пробовали разные подходы: запускали ночные пайплайны с полным запуском регресса, чтобы следить за состоянием системы между релизами, или при сборке конкретного сервиса настраивали дочерние пайплайны только с тестами этого сервиса. Но такие меры не решили проблему полностью: при больших релизах все равно запускался полный регресс, который мог идти двое суток. А при сбоях инфраструктуры общее время могло тянуться до нескольких дней.
Проанализировали нашу работу и выделили ряд основных проблем.
Сложная и разнородная технологическая база. Haskell + Scala в одном контуре затрудняют сопровождение и унификацию процессов.
Внешнее хранение тестов. Расположение в отдельном репозитории мешает синхронизации с кодом.
Отсутствие автоматического запуска в CI/CD. Тесты не блокируют пайплайн, баги могут свободно попасть в релиз.
Долгий прогон регресса. От суток до двух-трех дней при сбоях инфраструктуры.
Преобладание медленных Е2Е-тестов. Зависимость от стендов и работа только на них со всеми ограничениями.
Низкая стабильность тестов. Около 40 % успешных прогонов, много «флаки».
Высокие трудозатраты QA на поддержку. Разбор сотен упавших тестов, ручные перезапуски и расследования причин.
Мы поняли, что так продолжаться не может. Релизы стали занимать по два дня, QA тратили дни на перепрохождение неудачных кейсов, а дух команды падал перед каждым запуском. Мы подошли к необходимости срочных изменений. Нужен был инструмент, который позволил бы быстро сдвинуть дело с мертвой точки — без масштабных перестроек и сложных внедрений, но с ощутимым результатом уже в ближайшей перспективе. При этом не было возможности обучать команды новым языкам ради кардинального пересмотра подхода к тестированию.
Исходя из наших проблем мы сформулировали требования к новому инструменту тестирования:
Стабильность и предсказуемость окружения — исключить влияние предыдущих прогонов.
Быстрая обратная связь — интеграция в CI/CD и автоматический запуск при изменениях.
Минимальные трудозатраты на внедрение — использовать знакомый стек и опыт команды.
Гибкость и расширяемость — возможность дополнять функциональность под наши процессы.
Производительность — ускорить прогон тестов без потери качества.
Отсутствие необходимости переучивать команды — сохранить TypeScript/JavaScript как основу.
Первый шаг: Jest и чистое окружение
В качестве базы для нового фреймворка мы остановились на TypeScript. QA-инженеры уже использовали этот стек на фронтенде, а для бэкенд-разработки не нужны глубокие знания ЯП для поддержки и написания тестов. Выбор TypeScript казался наиболее практичным в нашем случае.
Основной формат обмена данными — JSON, экосистема JS/TS обеспечивает для него наиболее естественную поддержку. А еще у нас накопились наработки для автоматизации и внутренние интеграции с фронта, которые можно было быстро адаптировать под новое решение. Так мы сократили трудозатраты на внедрение и получили ощутимый результат в короткие сроки.
В первой итерации мы взяли Jest и построили на нем минимальный фреймворк, где окружение каждый раз полностью пересоздавалось через docker-compose с инициализацией в globalSetup. Такой подход гарантировал «чистоту» окружения: никакие данные от предыдущих прогонов не оставались в контейнерах. В результате тесты стали вести себя предсказуемо — они стабилизировались и перестали произвольно падать. Проблема случайных flaky-тестов была решена.
С самого начала мы понимали, что управлять множеством YAML-конфигураций для docker-compose сложно. Как только в нашей инфраструктуре появилась стабильная поддержка Testcontainers, мы заменили docker-compose на программный API testcontainers-node.
Теперь в globalSetup достаточно было нескольких строк кода: создать внутреннюю docker-сеть, поднять нужные контейнеры приложений, дождаться их готовности и автоматически удалять контейнеры после завершения теста. Конфигурация тестового окружения перешла из пугающей кучи YAML в лаконичный код. Это позволило полностью пересоздавать окружение при каждом запуске, что сделало процесс более надежным и предсказуемым.
После стабилизации тестов появилась возможность использовать их для блокировки пайплайна — теперь падения реально отражали проблемы сервиса. Мы перенесли тесты в репозитории приложений рядом с кодом, перестроили CI-пайплайны и настроили так, чтобы при сборке каждого сервиса автоматически запускались только тесты, относящиеся к нему. Тесты стали надежным инструментом контроля качества, встроенным в процесс разработки. В итоге время полного прогона регресса сократилось и стало сравнимо со временем выполнения основного пайплайна.

Добившись стабильности и предсказуемости тестов за счет чистого, изолированного окружения, мы подумали о следующем узком месте — скорости прогонов. Мы переосмыслили выбор тест-раннера: решили найти решение, которое сохранит достигнутую надежность, но при этом улучшит производительность и повысит скорость тестирования в долгосрочной перспективе.
Шаг второй: миграция на Vitest. Преимущества и задел на будущее
Долгое время в проекте мы использовали Jest как тест-раннер, поскольку он был популярен и опробован командой ранее. Окружение для интеграционных тестов разворачивали через Testcontainers, что позволяло программно запускать необходимые контейнеры для сервисов. А летом 2024 года мы увидели упоминание Vitest в обзоре State of JS 2023 и задумались, не стоит ли попробовать заменить Jest новым инструментом.
Vitest позиционируется как современный фреймворк для модульного тестирования, оптимизированный для скорости и легкой настройки. У него нативная поддержка ESM и TypeScript без дополнительных настроек. API Vitest совместим с Jest и хорошо знаком по предыдущему опыту. А еще у Vitest более гибкий lifecycle.
Особенно привлекали возможности публичного низкоуровневого программного API, более эффективное использование ресурсов и тонкая настройка параллельных прогонов.
Преимущества Vitest:
Производительность и ресурсы. На наших GitLab Runner’ах мы упирались в память при запуске Jest уже на трех потоках. Vitest обещал более бережное потребление RAM за счет оптимизированной организации процессов.
Расширяемость. Vitest дает возможность глубоко настраивать процесс тестирования: можно встроить запуск Testcontainers в lifecycle, а с помощью программного API задавать собственную точку входа через CLI. Это давало нам гибкость конфигурации тестов и механизмов развертывания среды.
Простота миграции. Vitest позиционирует себя как drop-in replacement для Jest. Предварительные измерения показали, что уже при простой замене раннера можно получить ускорение прогонов примерно на 10—30%. И это без рефакторинга тестов.
Мы решили заменить тест-раннер. Но сначала нужно было перевести проект на ESM (ES Modules). Vitest нативно работает с ESM, поэтому переход на него стал не столько подготовительной задачей, сколько первым этапом во всем процессе.

Переход на ESM
Первым делом мы перевели кодовую базу нашего фреймворка на формат ECMAScript Modules. Это модульная система JavaScript, стандартизированная в ES6, в отличие от устаревшего CJS (CommonJS), который использовался изначально.
Для этого в package.json мы добавили поле "type": "module", а в tsconfig.json выставили module: "NodeNext" и target: "ESNext". Потребовалось пересмотреть все точки входа и экспорты пакета. Итоговый package.json выглядел так:
{
"name": "@zoya/vite",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"exports": { ".": "./dist/index.js" },
"types": "dist/index.d.ts"
}
Мы заменили некоторые библиотеки, использующие CJS, на аналоги, поддерживающие ESM. Например, вместо lodash стали использовать lodash-es, а абсолютные пути вида @/lib/... заменили на относительные импорты или ссылки через workspace.
Еще поправили импорты в коде: в ESM требуется указывать расширение для относительных путей, поэтому в таких импортах мы добавили *.js. Например:
- import { createContainer } from '@/containers';
+ import { createContainer } from '../containers/index.js';
Глобальные переменные CommonJS filename и dirname в ESM заменили на эквивалентные через import.meta.url.
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
После правок мы получили стабильную ESM-версию фреймворка, с которой можно было запускать синтетические тесты. Мы сравнили результаты Jest и Vitest во время тестирования тестового приложения на 300 тестов по пяти запускам.
Раннер |
Pool |
Isolate |
Concurrent |
Потоков |
Время тестов (среднее, сек) |
Упавших |
RAM max (GB) |
Jest |
workers |
true |
1 |
5 |
176 |
0 |
4,79 |
Jest |
workers |
true |
1 |
3 |
206 |
0 |
3,89 |
Jest |
workers |
true |
1 |
1 |
539 |
0 |
2,96 |
Vitest |
forks |
false |
5 |
5 |
135 |
0 |
2,41 |
Vitest |
forks |
false |
5 |
3 |
180 |
0 |
2,20 |
Vitest |
forks |
false |
5 |
1 |
326 |
0 |
2,08 |
Vitest в режиме forks показал лучшее время исполнения по сравнению с Jest: 135 секунд против 176. При этом потребление памяти уменьшилось почти вдвое: 2,4 ГБ против 4,8.
Выводы по первым тестам:
Режим
pool: 'forks'оказался эффективнее с точки зрения скорости. Опцияthreadsпотребляет больше памяти и не дает значительного выигрыша в наших условиях. РежимыvmThreads/vmForksне поддерживают ESM, поэтому мы их не использовали.Кэширование
cache: trueускоряет последующие прогоны тестов. Отключенный кэш увеличивает время выполнения.Параллельное выполнение тестов внутри одного файла —
concurrent, в большинстве клиентских приложений обычно установлено на1. В наших синтетических тестах это почти не влияло на результат, поэтому мы использовали стандартные настройки.
Оптимальная конфигурация на локальной машине давала Vitest преимущество и по скорости, и по экономии памяти.
Исправления с публикацией ESM-пакетов. Сборкой проекта у нас управляет NX. Мы столкнулись с тем, что при билде пакета он продолжал публиковаться в CJS-формате. Оказалось, что в версиях @nx/devkit до 19.6.0 был баг: все билд-сервисы, кроме nx:vite/nx:esbuild, принудительно устанавливали type: commonjs в метаданные. После обновления NX до версии 19.6.0, где этот баг исправили, сборка ESM-пакетов заработала корректно.
После этого мы протестировали Vitest на клиентском приложении, обновили зависимости и провели тестирование в разных конфигурациях.
Сравнение конфигураций Vitest в локальной среде
На локальной машине мы замерили производительность без какого-либо изменения тестов. Перебрали несколько вариантов конфигураций, но для наглядности остановимся на ключевых. В таблице собрали результаты для текущего набора тестов проекта — всего 1829 тест-кейсов. Время в секундах указано по среднему из нескольких запусков.
Runner |
Pool |
Cache |
Isolate |
Concurrent |
Потоков |
Время (с) |
Упавших тестов |
Комментарий |
|---|---|---|---|---|---|---|---|---|
✅ Vitest |
forks |
true |
false |
false |
18 |
215 |
199 |
Самое быстрое выполнение, но много падений |
✅ Vitest |
forks |
true |
true |
false |
18 |
269 |
95 |
Отличный баланс по времени/стабильности |
⚠️ Vitest |
threads |
true |
true |
false |
3 |
262 |
SEGFAULT |
Краш из-за изоляции |
⚠️ Vitest |
vmThreads |
— |
— |
— |
3 |
>900 |
SEGFAULT |
Полностью нестабильно |
? Vitest |
forks |
true |
true |
false |
3 |
663 |
45 |
Очень медленно, хоть и стабильно |
? Vitest |
forks |
false |
false |
true (5) |
3 |
275 |
432 |
Много падений при concurrency |
Результаты экспериментов:
Наилучшее время — 215 секунд, но при этом было много падений: 199 из 300 тестов упали. Достигается при конфигурации:
pool: 'forks',
cache: true,
isolate: false,
threads: 18
Лучший баланс между скоростью и стабильностью получился, когда за 269 секунд было всего 95 падений. Конфигурация:
pool: 'forks',
cache: true,
isolate: true,
threads: 18
Попытки использовать pool: 'threads' или pool: 'vmThreads' с isolate: true приводили к ошибке SEGFAULT из-за нехватки памяти. Они оказались непригодными к применению в нашем случае.
Опция isolate: true клонирует зависимости для каждого потока и сбрасывает глобальные состояния, что заметно увеличивает потребление RAM. Польза от этого в наших сценариях невелика, поэтому мы оставили isolate: false по умолчанию.
Без параллелизма внутри файлов и с небольшим числом потоков время сильно растет — такой режим хоть и стабильный по падениям, но слишком медленный.
В итоге мы выявили оптимальную стратегию: запускать Vitest с pool: 'forks', включенным cache: true, регулировать число потоков в зависимости от ресурсов и включать isolate для отдельных прогонов критичных сценариев. Изоляция исключает «протечку» состояния между тестами и повышает надежность. А еще изоляция увеличивает потребление ресурсов, поэтому используется выборочно для сценариев с высокой критичностью или когда есть сомнения в качестве самих тестов.
Настройка Vitest в CI
На наших CI-раннерах ресурсы ограничены: 3 CPU 6 GiB RAM (лимит процесса Node подняли примерно к рамкам ресурсов раннера). Мы повторили замеры производительности в CI. Условия взяли аналогичные локальному измерению, но с учетом ограничений инфраструктуры. Ключевые результаты собрали в таблицу.
Runner |
forks/threads |
cache |
isolate |
concurrent |
Потоки |
Время, с |
Улучшение |
Память, ГБ |
CPU |
Примечания |
|---|---|---|---|---|---|---|---|---|---|---|
Vitest |
forks |
true |
false |
5 |
3 |
~305 |
-57% |
~3.3 |
~1.6 |
Оптимальный баланс скорости и ресурсов |
Vitest |
forks |
true |
false |
false |
6 |
~310 |
-56% |
~4.3 |
~1.6 |
Легкий рост потребления памяти при стабильной скорости |
Vitest |
forks |
true |
false |
false |
12 |
~173 |
-76% |
~5.7 |
~2.5 |
Значительный прирост производительности, но близко к лимитам памяти |
Vitest |
forks |
true |
false |
false |
16 |
~159 |
-78% |
~5.8 |
~2.5 |
Максимально возможная нагрузка без OOM |
Jest |
workers |
true |
true |
— |
3 |
~707 |
0% |
~3.9 |
~1.5 |
Базовый сетап |
Jest |
workers |
true |
true |
— |
6 |
? |
— |
>5.8 |
~2.0 |
Переполнение памяти при увеличении потоков |
Основные наблюдения:
Vitest в конфигурации
forks+cache: true+isolate: falseна трех потоках сокращает время тестов более чем вдвое по сравнению с Jest, при этом потребление ресурсов остается умеренным. 305 секунд против 707.Увеличение числа потоков до 12 еще сильнее снижает время выполнения. Но еще увеличение числа потоков приближает использование памяти к лимиту CI, что ограничивает дальнейшее масштабирование.
Включение изоляции
isolate: trueрезко увеличивает потребление памяти и замедляет выполнение, поэтому мы отказались от нее в CI.Попытки включить
threadsприводили к ошибкам сегментации из-за нехватки памяти, поэтому этот режим мы не использовали в CI.Оптимальным решением стала настройка
pool: forks, cache: true, isolate: falseс динамическим выбором числа потоков от 6 до 12. Такая конфигурация сохраняет баланс между скоростью и стабильностью.
Настройка Vitest практически совпала с рекомендациями официального гайда миграции с Jest — потребовалось лишь минимум изменений и Vitest «из коробки» почти сразу заменил Jest.
Проблемы и решения при переходе с Jest на Vitest
При переходе на Vitest мы столкнулись с рядом особенностей, которые отсутствовали в Jest.
Проблема: Vitest падает с ошибкой Cannot import Vitest outside of test run.
Причина: мы импортировали Vitest или @zoya/vitest в globalSetup. Vitest не позволяет так делать вне процесса тестирования.
Решение: настроили exports для модуля @zoya/vitest, чтобы обходить это ограничение. Например, предоставили фасадный модуль.
Проблема: тесты не изолированы, данные «перетекают» между тестами.
Причина: мы отключили isolate из соображений памяти, поэтому модули подгружаются один раз и состояния, особенно глобальные, сохраняются между тестами.
Решение: переписали утилиты так, чтобы не хранить статическое состояние, начали очищать моки и кэши между тестами, создавать новые экземпляры сервисов. Где нужно — включили isolate: true для критичных сценариев
Проблема: не все импорты мокируются правильно.
Причина: в комбинации ESM + Vitest механизмы моков работают иначе. Некоторые зависимости из CommonJS не мокировались.
Решение: явно смокировали все зависимости, которые раньше брались из CJS. Для некоторых утилит пришлось вручную мокать объекты и функции<br>.
Проблема: работа таймеров fake и realTimers в Vitest отличается от Jest.
Причина: реализации мокирования таймеров в Vitest и Jest различаются.
Решение: пока решили оставить как есть, в дальнейшем, возможно, перепишем под новую модель таймеров.
Проблема: шпионы (spy) не срабатывают на внутренние методы.
Причина: при ESM и при импортах функций Vitest иногда не может отследить эквивалент jest.spyOn на функцию, особенно на геттеры.
Решение: искать обходные пути. Можно экспортировать функции напрямую (не через геттеры) или устанавливать моки и заглушки внутри самих модулей.
Проблема: неатомарность модулей и скрытые зависимости между ними.
Причина: особенности загрузки модулей в Vitest выявили скрытые зависимости между модулями, например import.meta.url вместо __dirname и так далее.
Решение: переработали связанные куски кода, разгрузили зависимости между модулями. Написали кастомный раннер, сбрасывающий состояние между тестами.
Проблема: задержка поддержки NX-плагинов и Allure.
Причина: Vitest развивается быстрее, чем обновляются плагины NX или Allure под него.
Решение: для NX часть плагинов не дожидались обновлений, мы перешли на Turborepo вместо старых плагинов. Для Allure пришлось ждать обновлений репортера и дорабатывать его под себя.
Проблема: недостаток документации по Vitest.
Причина: Vitest — относительно новый инструмент, информации в сети меньше, чем по Jest.
Решение: смириться и участвовать в сообществе или изучать исходники. Писать issues, следить за обновлениями и так далее.
После решения всех проблем мы выпустили мажорную версию фреймворка и начали миграцию на Vitest во всех клиентских приложениях. Миграция проектов оказалась проще, чем мы ожидали: нам помог инструмент ts2esm, который автоматически перевел кодовую базу с CommonJS на ESM. После этого оставалось только пройтись по коду и вручную поправить не охваченные проблемы, но основную работу проделал именно ts2esm.
Результаты миграции
Итоговые метрики не обманули наших ожиданий — скорость полного прогона тестов выросла. В среднем по проектам время регресса сократилось примерно на 6%, а для большинства отдельных сервисов прогон стал быстрее примерно на 25% (кроме монорепозиториев).
Не во всех сервисах удалось ускорить прогон. Без isolate: false в некоторых самописных обертках тестов оставались состояния между тестами, что приводило к утечкам данных и увеличивало потребление RAM. Чтобы избежать этого, для части приложений мы явно ограничили число потоков до трех threads: 3, сохранив isolate: false. Кроме того, у Vitest оказался неидеальным алгоритм распределения тестов по файлам: некоторые тяжелые тесты собирались в одну «пачку» и попадали в конец прогона, что увеличивало время выполнения.
Нам помогла практика жесткого контроля окружения. Помимо встроенного механизма retry в Vitest, мы внедрили дополнительный механизм повторных прогонов упавших тестов — мы называем его hardRetry. Если тесты падают, мы собираем список упавших кейсов и сразу запускаем их еще раз в том же контексте, но уже на свежевосстановленном окружении. Мы полностью пересоздаем контейнеры для запуска. Это позволяет стабилизировать результаты CI-прогона, исключая влияние остаточных ошибок, и при этом экономить время и ресурсы раннеров за счет сокращения повторных запусков job.
Мы сократили общее время работы джоб с тестами примерно на 21% — с 9583 до 7550 часов в неделю. Уменьшили количество повторных прогонов на ~19% — с 1279 до 1042 запусков в неделю. При этом 95-й перцентиль времени выполнения джобы остался примерно тем же: ~19 минут при ~3800 тестах.

Результаты подтвердили, что переход на Vitest сделал наше тестирование быстрее, стабильнее и эффективнее.
Будущее с Vitest
Переход на Vitest стал отправной точкой для развития нашей тестовой инфраструктуры и повышения эффективности разработки. Vitest дал нам более быструю, стабильную и гибкую платформу, которая позволила настроить процесс тестирования под наши нужды и создать удобные инструменты для команд.
Нам видится большой потенциал в развитии системы тестирования на базе Vitest. Это касается не только улучшения инфраструктуры, но и создания инструментов для повышения качества кода и ускорения обратной связи.
Мы начали с амбициозной цели — избавиться от нестабильности тестов, ускорить поставку и сделать тестирование неотъемлемой частью процесса разработки. Путь был непростым: от анализа текущего состояния и выбора подходящего инструмента до миграции кода и адаптации инфраструктуры.
Планы:
Расширение системы плагинов. Добавление новых плагинов для интеграции с системами мониторинга, анализа кода и управления инцидентами.
Watch-режим для ускоренной разработки. Внедрение автоматического перезапуска тестов при изменении кода без ручного вмешательства, чтобы ускорить локальную отладку.
Углубленная аналитика прогонов. Сбор дополнительных метрик: времени выполнения, причин падений, покрытия кода — для выявления узких мест и повышения качества тестов.
Интегрированный лог-менеджмент. Разработка инструментов для удобного сбора и анализа логов из контейнеров, раннера Vitest и сервисов для более быстрой диагностики проблем.
Расширение CLI. Добавление команд для обновления зависимостей, создания новых проектов и других полезных утилит.
Параллельные прогоны в CI. Исследование возможности запуска нескольких проектов тестов в одной джобе для увеличения распараллеливания с учетом ограниченных ресурсов и повышения утилизации.
В итоге Vitest стал для нас не просто заменой Jest, а платформой для создания современной, эффективной и расширяемой системы тестирования. Мы уверены, что дальнейшее развитие интеграции Vitest в наш CI/CD позволит повысить качество продукта, ускорить разработку и снизить затраты на тестирование.
Используйте удобные инструменты и лучшие практики, опирайтесь на то, что уже работает, но не увязайте в привычном: всегда есть место для улучшения!