За последние пару лет не раз можно было услышать про новые инструменты сборки статики, такие как SWC, esbuild и Vite. Все они обещают нам next gen-оптимизацию времени сборки, а SWC ещë и грозится оптимизировать скорость выполнения тестов на Jest; более того, судя по документации, сделать это очень просто. Я решил проверить, так ли это на самом деле и каким будет результат. Если вам интересно, что из этого получилось и какие были проблемы, то прошу под кат.
Приветствую тебя, дорогой читатель. Меня зовут Денис, я лидер фронтенд-разработки в Домклик. И сегодня хочу рассказать вам про попытку сократить длительность CI-пайплайна с помощью минимальных кодо-движений. Причина выбора SWC проста, можно оставить транспилирование production-кода as is на Babel, так мы не рискуем сломать или частично повредить наш прод.
Зачем вообще оптимизировать скорость сборки, линтинга, тестов? Причин можно написать поистине много, но вот основные: улучшить процесс разработки, сэкономить время разработчика, а также ускорить CI, длительность которого может сыграть злую шутку во время экстренной выкатки важного исправления.
Что будем делать:
подключим SWС;
проведём сравнение локально;
проведём сравнение на сборочной машине;
сделаем выводы.
Подключаем @swc/jest
Для эксперимента выбрал небольшой и свежий (в плане зависимостей и кодовой базы) проект, где всего 74 тестовых набора с 231 тестом. Под капотом React, экспериментальный синтаксис и, конечно же, es-модули.
Подключение выглядит довольно просто, достаточно установить несколько библиотек.
# npm
npm i -D jest @swc/core @swc/jest
# yarn
yarn add -D jest @swc/core @swc/jest
Далее, в настройках jest заменим плагин, который отвечает за транспилирование, в моём случае это был babel-jest. С ним и будем сравнивать результат, но об этом позже.
Было:
/* jest.config.js */
transform: {
'^.+\\.(js|jsx)$': 'babel-jest'
}
Стало:
/* jest.config.js */
transform: {
'^.+\\.(js|jsx)$': '@swc/jest'
}
И, судя по документации, сейчас мне стоит пристегнуть ремень, ведь я точно должен нарушить скоростной режим. Но как бы не так, запускаю тесты и получаю печальный результат.
Test Suites: 68 failed, 6 passed
Наверное, наивно было полагать, что SWC сам разберëтся с jsx-трансформациями, ему нужно немного помочь. Сделать это можно через файл .swcrc
или же через jest.config.js
. Так как пока нет планов полностью заменить Babel на SWC, то создавать лишний файл не стал, решил разобраться на месте. Про все настройки можно прочитать тут, а сейчас посмотрим только на те, которые понадобились в моём случае.
Включить поддержку jsx-синтаксиса можно опцией jsc.parser.jsx = true
, а jsc.transform.react.runtime = automatic
понадобится для новой jsx-трансформации. Примеры кода по классике располагаются ниже.
Было:
/* jest.config.js */
transform: {
'^.+\\.(js|jsx)$': '@swc/jest'
}
Стало:
/* jest.config.js */
transform: {
'^.+\\.(js|jsx)$': ['@swc/jest', {
jsc: {
parser: {
jsx: true
},
transform: {
react: {
runtime: 'automatic'
}
}
}
}]
}
Ремень уже пристëгнут, я готов. Запускаю тесты.
Test Suites: 19 failed, 55 passed
Определëнный прогресс, конечно же, есть, но часть тестов по-прежнему падает, а в логах плюс-минус одинаковые сообщения.
TypeError: Cannot redefine property: useIntersection at Function.defineProperty (<anonymous>)
Stack trace привëл меня к методу spyOn
и импортируемому модулю. Логично было сравнить, что же получается на выходе после импорта.
/* via babel-jest */
{
__esModule: true,
pluralize: [Getter],
getCookie: [Getter],
hasCookie: [Getter],
setCookie: [Getter],
formatPhone: [Getter],
priceFormatter: [Getter],
scrollToAnchor: [Getter],
getRatingColor: [Getter]
}
/* via @swc/jest */
{
pluralize: [Getter],
getCookie: [Getter],
hasCookie: [Getter],
setCookie: [Getter],
formatPhone: [Getter],
priceFormatter: [Getter],
scrollToAnchor: [Getter],
getRatingColor: [Getter]
}
Жонглирование настройками модульности в SWC, к сожалению, не помогло. Но решение проблемы всë же нашлось в документации Jest.
When using the factory parameter for an ES6 module with a default export, the __esModule: true property needs to be specified. This property is normally generated by Babel / TypeScript, but here it needs to be set manually. When importing a default export, it's an instruction to import the property named default from the export object
Ниже приведу примеры старого кода и кода после рефакторинга.
Было:
/* начало примера */
import * as utils from 'utils';
import Price from '.';
describe('price component', () => {
const scrollToAnchor = jest.spyOn(utils, 'scrollToAnchor');
it('клик на кнопку "В продаже"', () => {
render(<Price />);
userEvent.click(screen.getByText(/Смотреть: 1 объявление/));
expect(scrollToAnchor).toHaveBeenCalledTimes(1);
expect(scrollToAnchor).toHaveBeenCalledWith('offers');
});
/* конец примера, дальше не интересно */
Стало:
/* начало примера */
import * as utils from 'utils';
import Price from '.';
jest.mock('utils', () => ({
__esModule: true,
...jest.requireActual('utils')
}));
describe('price component', () => {
const scrollToAnchor = jest.spyOn(utils, 'scrollToAnchor');
it('клик на кнопку "В продаже"', () => {
render(<Price />);
userEvent.click(screen.getByText(/Смотреть: 1 объявление/));
expect(scrollToAnchor).toHaveBeenCalledTimes(1);
expect(scrollToAnchor).toHaveBeenCalledWith('offers');
});
/* конец примера, дальше не интересно */
В случае, если бы задача сводилась к заглушке возвращаемого значения, без какого-либо интерактива в виде подсчёта количества вызовов или проверки входящих аргументов, то достаточно просто использовать jest.mock
без __esModule
.
/* начало примера */
import * as effects from 'effects';
import Panorama from '.';
jest.mock('effects');
describe('panorama component', () => {
beforeEach(() => {
effects.useIntersection.mockReturnValue(true);
});
/* конец примера, дальше не интересно */
Лёгким движением руки сломанные тесты починились.
Test Suites: 74 passed
Наконец можно перейти к самому интересному, а именно — замерить скорость.
babel-jest vs @swc/jest
Замеряю скорость выполнения тестов, получаю неожиданный результат, а ожидания были, естественно, что скорость вырастет.
babel-jest | @swc/jest |
33.19 s | 32.36 s |
32.98 s | 34.82 |
... | ... |
Все последующие десять замеров в таком же духе, визуально разницы нет никакой. «Выдумал сам себе оптимизацию?», – подумал я. Читаю документацию ещë раз, в ней отчётливо вижу: “Improving Jest performance”. Нашёл issue, прочитал. С одной стороны, кажется, что можно уже сворачивать эксперимент, но с другой стороны, этот топик заставил меня взять время на раздумье.
На следующий день, решил очистить кеш Jest перед прогоном тестов, сделать это можно через CLI.
jest --clearCache
Повторно провожу замер за замером, и, конечно же, каждый раз чищу кеш.
babel-jest | @swc/jest |
50.907 s | 35.163 s |
49.462 s | 34.878 s |
49.316 s | 39.499 s |
…. | …. |
Как видите, разница есть, и стоит отметить, что этот эксперимент проводился на относительно небольшом проекте. Глядя на эти числа, можно смело сказать, что это своего рода хороший cheap tuning, но «вау-эффекта» не возникает. Не изучал исходники, но догадываюсь, что весь профит — это, скорее всего, транспилирование, и на сам процесс выполнения тестов SWC никак не влияет, что, конечно же, логично, ведь речь идëт про "speedy web compiler".
Но подводить итоги ещë рано, нужно протестировать, каков будет результат на сборочной машине в целом по CI.
@swc/jest пытается уехать на dev-стенд
Как только код уехал из локального окружения, начались проблемы. Вы, наверное, уже догадались, что тесты упали.
На самом-то деле в данном случае это помогло обратить внимание на одну не совсем очевидную вещь. Но обо всëм по порядку, начнём с ошибки и причины её возникновения.
Bindings not found
Ошибка была вызвана тем, что для работы SWC в разных окружениях нужны разные бинарники. Например, для macOS нужен был @swc/core-darwin-x64, и хорошая новость в том, что устанавливаются они всë так же через NPM, более того, SWC делает это сам, выбирая нужный бинарник в зависимости от текущего окружения. Так как мы используем прокси-хранилище для зависимостей, то нужно было добавить в него @swc/core-linux-x64-musl, этот бинарник необходим для работы SWC под Alpine Linux. А вот уже после этого тесты успешно прошли. Если вы устанавливаете зависимости напрямую из NPM, то с этой проблемой, скорее всего, не столкнётесь. На всякий случай оставлю тут актуальный список бинарей.
«Финишная прямая», — подумал я и начал делать десятки замеров. Все они показали похожий результат, давайте разберëм один из этих замеров подробнее:
транспайлер | установка зависимостей | тесты | полный цикл CI |
babel-jest | 49 s | 20.82 s | 2.65 m |
@swc/jest | 53 s | 9.962 s | 2.6 m |
Как можно заметить, разницы в полном цикле CI почти нет, и на этот раз дело даже не в кеше. При детальном изучении логов я увидел, что количество npm-модулей увеличилось с 2018 до 2075, увеличилась и длительность установки зависимостей. Помните, в начале этой главы я писал про «не совсем очевидную вещь»? Так вот, один только бинарник под Alpine в распакованном состоянии весит почти 79 Mб. Из-за этого Docker-образ начал весить больше, чем раньше, а мы начали тратить больше времени на его выгрузку, нивелировав всю разницу в скорости транспилирования для выполнения тестов. Что в итоге? Ускорили транспилирование, но в то же время замедлили установку зависимостей и последующий пайплайн для Docker-образа.
Ну что же, пришло время подвести итоги!
Выводы
Подключить SWC оказалось довольно просто, но не обошлось без рефакторинга, который пропорционально количеству тестов может иметь разную сложность.
SWC без кеша оказался действительно быстрей. Это не поможет нам в разработке, но потенциально может улучшить скорость CI, если речь идёт про большие проекты. В ином же случае овчинка выделки не стоит.
Да, можно сделать базовый образ Docker с уже предустановленными зависимостями, и тогда профит будет уже и на маленьких проектах, но это затраты на разработку и поддержку этого же образа.
В целом, если рассмотреть полный перевод транспилирования, включая production-код, из Babel в SWC, то, скорее всего, выгода будет даже безо всяких базовых образов. Но это совсем другая история, мем не добавляю, думаю, вы уже поймали флешбэк с Леонидом.
Что касается меня, я, скорее всего, продолжу дальше копаться в SWC; не то чтобы Babel меня не устраивал, но если что-то может позволить ускорится, то почему бы это и не сделать. А ещë я постараюсь перестать забывать про наличие кеша.
Пробовали ли вы использовать SWC у себя на проектах, если да, то какие результаты получили?