С Karma на Vitest: как мы обновляли unit-тесты Angular и починили Allure

Привет! На связи Дмитрий Чепайкин, старший Angular-разработчик в команде Klecks — платформы для разметки данных AI Центра Т-Банка.
Долгое время unit-тесты фронтенда у нас крутились на Karma + Jasmine. Схема работала, пайплайн зеленый, переписывать «ради моды» не хотелось. Но в какой-то момент Karma официально признали deprecated, а корректная отгрузка отчетов в Allure перестала быть чем-то, на что можно положиться. Расскажу, как мы выбирали замену, почему сначала смотрели на Jest, а в итоге приняли Vitest — и как устроен итоговый сетап.
Триггеры миграции
Во фронтенде Klecks три тестовых проекта: customer, operator и shared. Кодовая база включает сотни *.spec.ts, использует Angular TestBed, моки сервисов, фейковые таймеры и кастомные хелперы вроде UnhandledCatcher. При этом интеграционные сценарии уже давно выполнялись на Playwright, а unit-тестирование продолжало опираться на классический Angular-стек: Karma через builder @angular-devkit/build-angular:karma, Jasmine в качестве тестового API и Chrome Headless для запуска в CI. Покрытие кода собиралось через karma-coverage, а результаты unit-тестов пытались выгружать в Allure вместе с e2e-тестами.
Формально все работало. Но стоимость поддержки стека росла, а на Karma уже не решалась надежно одна из задач качества — нормальная иерархия unit-тестов в Allure. Поэтому стоял вопрос не «Нужен ли нам новый раннер?», а «Какой раннер позволит уйти с deprecated-инструмента и при этом не развалить существующие тесты и отчетность?».
Зачем трогать то, что работает
Karma справлялась со своими задачами годами. Руки до миграции не доходили не потому, что стек был идеален, а потому, что он был «достаточно хорош». Локально ng test открывал браузер, в CI Chrome Headless прогонял suites. Менять инфраструктуру при живом продукте — всегда риск.
Мы собрали список проблем.
Deprecated и тупиковая поддержка. Karma больше не развивается как основной современный test runner, поэтому дальнейшая интеграция с актуальным инструментарием требует все больше поддержки legacy-слоя. Оставаться на Karma — значит тащить устаревающий слой между CLI и тестами, который рано или поздно упрется в несовместимость с новой версией Angular/Webpack/Vite.
Allure для unit-тестов. Для e2e у нас уже был рабочий контур Playwright → Allure. Для unit на Karma отчеты не собирались, так как не было стабильного плагина, полноты и стабильности в выгрузке артефактов тестов. В CI это превращалось в «тесты зеленые, а в Allure unit-слой неполноценный». Именно эта боль стала триггером: отчетность — часть процесса, а не опциональный бантик.
Тяжелый браузерный рантайм в CI. Karma поднимает реальный Chrome. Для части DOM-тестов это плюс, но для сотен unit-спеков — лишняя тяжесть образа, флаки по браузеру и более дорогой пайплайн. Cypress-образ ради unit-прогона сам по себе сигнал, что инфраструктура разъехалась с задачей.
Jasmine-специфика. API привычный, но экосистема вокруг моков, таймеров и матчеров все сильнее расходится с тем, что используют Jest/Vitest-мир и современные Allure-репортеры. Любая новая интеграция требовала прокладок под Karma и Jasmine, а не готового first-class-плагина.
Основными целями миграции стали отказ от устаревшего Karma, обеспечение корректной выгрузки результатов unit-тестов в Allure и сохранение существующей бизнес-логики тестов. Изменения должны были затронуть только инфраструктуру тестирования и API тестового раннера.
Рассматривали: Jest vs Vitest
Мы не прыгали сразу в финальный выбор. Сначала на ветке test_jest собрали полноценный прототип на Jest: jest-preset-angular, отдельные конфиги создали на customer / operator / shared, allure-jest, добавили адаптеры под Jasmine-совместимые хелперы (expectAsync, toBeTrue / toBeFalse, глобальный spyOn). Это был честный spike: можно ли перенести сотни спеков и закрыть Allure.
Эксперимент с Jest показал, что можно закрыть основные боли:
нативный Node + jsdom без Chrome;
официальный путь через
jest-preset-angular;рабочий
allure-jestс иерархией лейблов;параллельный прогон проектов.
Но именно для нашего Angular-монорепо появились минусы:
отдельный контур мимо Angular builder — скрипты
jest --config ...вместоng test;свой зоопарк конфигов (
create-jest-config,tsconfig.jest.json, project configs);необходимость compatibility-слоев под старый Jasmine API;
трансформ ESM/CJS-зависимостей (
transformIgnorePatternsна длинный список пакетов Taiga/Angular);два мира инструментов: CLI/сборка на одном стеке, тесты — на другом.
Jest как технология нас не провалил. Он показал, что миграция с Karma возможна и что Allure на Node-раннере реально оживает. Но как целевой стек для Angular 21 он выглядел компромиссом, а не финальной точкой.
В итоге мы выбрали Vitest. После Jest-spike мы собрали решение на Vitest — коммит с принятым направлением: миграция builders, конфигов и спеков. Выбор опирался на набор практических критериев.
Интеграция Vitest через Analog позволила сохранить запуск через Angular CLI и стала подходящим вариантом для нашего текущего workspace. Vitest подключается через @analogjs/vitest-angular:test. Для разработчика вход тот же:
ng test customer
ng test operator --configuration=ciВ angular.json вместо karma-builder:
"builder": "@analogjs/vitest-angular:test",
"options": {
"configFile": "vitest.config.mts"
}Мы остаемся внутри Angular CLI, а не уходим в параллельную систему запуска.
Миграция на Vitest для нас закрыла ряд вопросов.
Vite-based pipeline вместо отдельного Jest transform layer. Vitest работает на Vite: быстрее старт, нативная работа с ESM, проще inline зависимостей Angular/Taiga через server.deps.inline. Там, где в Jest копился transformIgnorePatterns, в Vitest мы описываем resolve/alias и список пакетов для инлайна — и это лучше ложится на текущий фронтенд-стек.
Прямая интеграция с Allure через allure-vitest. allure-vitest/reporter пишет результаты в allure-results/unit/<project>. Плюс общий setup автоматически проставляет иерархию: parentSuite → suite → layer → epic → subEpic.
То есть unit-слой наконец встает рядом с интеграционными отчетами по тем же правилам качества.
Меньше «клея», ближе к целевому API. Vitest из коробки дает vi.fn, vi.spyOn, fake timers, globals. Jasmine → Vitest — механическая замена в спеках, без отдельного compatibility-runtime, который пришлось бы сопровождать на Jest.
CI без браузера и с контролем воркеров. Unit-джоба переехала на Node-образ. Воркеры ограничены явно: в CI VITEST_MAX_WORKERS=2, потому что Angular + jsdom + coverage прожорливы по RAM, а K8s часто отдает чужой CPU count. Это осознанная настройка под нашу инфраструктуру, а не дефолт «съешь все ядра».
Вывод: Jest доказал миграцию, Vitest лучше сел как целевой раннер для Angular-приложений Klecks.
Миграция самих тестов
Большая часть работы — не переизобрести тесты, а перевести API. Вот типичные замены.
Было (Jasmine) | Стало (Vitest) |
jasmine.createSpy() | vi.fn() |
spyOn(obj, 'method') | vi.spyOn(obj, 'method') |
jasmine.createSpyObj(...) | ручной stub / vi.fn на методах |
and.returnValue(x) | mockReturnValue(x) |
and.callFake(fn) | mockImplementation(fn) |
calls.reset() | mockClear() / mockReset() |
jasmine.clock().install() | vi.useFakeTimers() |
jasmine.clock().tick(n) | vi.advanceTimersByTime(n) |
jasmine.objectContaining | expect.objectContaining |
done()-колбэки | async/await + Promise |
jasmine.Spy в типах | Mock из vitest |
TestBed, ComponentFixture, провайдеры и структура describe/it почти не менялись.
Важно, что миграция раннера не должна была стать рефакторингом продуктового кода. Мы отдельно подтянули инфраструктурные хелперы. Например, UnhandledCatcher научили ловить и window.onerror, и rxjs.config.onUnhandledError. В jsdom/Vitest необработанные ошибки Observable ведут себя иначе, чем в Karma/Chrome. Такие места всплывают только на реальном прогоне, поэтому spike на Jest тоже был полезен: часть проблем API мы увидели еще до финального выбора.
Allure: как закрыли исходную боль
Что мы сделали по шагам:
Репортер пишет в
allure-results/unit/<project>.Автолейблы строят иерархию из пути файла и имени теста.
Epic выбирается по расположению: customer / operator / shared.
Локально можно собрать и открыть отчет теми же npm-скриптами, что и для integration
Параллелизм: Chrome Headless / Karma → Vitest threads, maxWorkers=2
В итоге unit-слой перестал быть «второй реальностью» относительно Playwright: и там и там Allure понимает слой, suite и продуктовый epic.
Что принесла миграция на vitest.
Было | Стало | |
Раннер | Karma + Jasmine + Chrome | Vitest + jsdom (+ Analog builder) |
Статус стека | deprecated / legacy path | Актуальный Angular-friendly-путь |
Запуск | ng test через karma-builder | ng test через @analogjs/vitest-angular:test |
Allure unit | Ненадежная/неполная выгрузка | allure-vitest + автолейблы |
CI-образ | Тяжелый browser-based | Node 22 |
Параллелизм | Браузерный single-run контур | threads + явный лимит воркеров |
Скорость джобы в CI | ~3 минуты 40 секунд | ~2 минуты 20 секунд |
Плюсы, которые мы получили:
ушли с deprecated Karma;
сохранили привычный DX через
ng test;unit-отчеты в Allure стали частью нормального процесса;
CI стал проще по зависимостям (без Chrome ради unit);
спеки мигрируют механически, без переписывания сценариев;
ускорили джобу unit тестов в CI на ~36,4%.
Но есть и компромиссы. Они для нас— разумная цена за поддерживаемый стек и рабочую отчетность. Компромиссы, о которых стоит знать:
jsdom ≠ полный браузер: редкие DOM/CSS edge-cases могут вести себя иначе;
в CI нельзя бездумно крутить maxWorkers: coverage + Angular TestBed легко упираются в память;
часть хелперов (
UnhandledCatcher, fake timers) потребовала точечной адаптации под новый рантайм.
Нативный Angular Vitest (@angular/build:unit-test) мы сознательно не брали, потому что для нашего кейса он слишком закрытый. Официальный builder заточен под zero-config: Angular сам поднимает Vitest, а кастомный vitest.config либо ограничен, либо поддерживается частично.
Команда Angular прямо говорила: если нужен полноценный vitest.config.ts (плагины, репортеры, тонкая настройка), берите Analog.
Для нас такой уровень контроля был принципиален. Требовалось интегрировать allure-vitest/reporter с собственной иерархией лейблов, создать единый factory-конфиг для customer, operator и shared с общими алиасами, путями Less и директориями покрытия. Кроме того, необходимо было управлять количеством воркеров через VITEST_MAX_WORKERS, чтобы учитывать ограничения памяти в K8s при запуске Angular-тестов с jsdom и coverage, а также гибко настраивать параметры рантайма, включая isolate, inline deps и setupFiles.
Analog (@analogjs/vitest-angular) дает полный контроль «из коробки» и при этом оставляет ng test. Jest-spike уже показал, что «просто зеленых тестов» недостаточно — нужна управляемая инфраструктура и отчеты.
Native имеет смысл для зеленого поля или простых приложений. Для нашего монорепо с Allure и жестким CI Analog оказался практичнее. Позже, когда @angular/build:unit-test догонит по конфигурируемости, можно будет пересмотреть — но на момент решения Analog закрывал требования лучше.
Заключение
Мы не меняли раннер, потому что «все переходят на Vitest». Триггером стала связка из двух фактов: Karma deprecated и Allure для unit на текущем стеке больше не закрывал требования качества.
Jest на ветке test_jest подтвердил, что Node-раннер + Allure — рабочий путь. Vitest выбрали как целевое решение, потому что он лучше стыкуется с Angular CLI через Analog, естественнее живет с Vite/ESM и дает прямую интеграцию с Allure через allure-vitest без отдельной параллельной инфраструктуры запуска.
Итог миграции:
один shared Vitest-конфиг на три проекта;
builder в
angular.jsonбез Karma;автолейблы Allure в
@klecks/testing;механический перевод Jasmine API → Vitest в спеках;
CI на Node с ограниченными воркерами;
unit-тесты в CI проходят на ~36,4% быстрее;
Если у вас похожий Angular-монолит на Karma и уже болит Allure/поддержка, имеет смысл сначала сделать короткий spike на Jest или Vitest именно с выгрузкой отчетов, а не только с «тесты зеленые локально». У нас зеленость никогда не была главной проблемой. Главным было сделать так, чтобы unit-слой снова был наблюдаемым и поддерживаемым.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.