Node не исполняет TypeScript напрямую, а тесты вы пишете именно на нём - значит, между исходником и рантаймом обязан встать трансформер. Трансформер - это шаг, который превращает TypeScript и JSX в JavaScript, понятный текущей версии Node. Отсюда вырастают два независимых развилки: чем компилировать типы и в каком модульном формате запускать код - привычном CommonJS или нативном ESM. Продолжим сквозной пример: функция calculateDelivery лежит в модуле на TypeScript, и от выбора на этих развилках зависит, соберётся ли тест вообще.
Естественный, но вредный импульс - включить всё сразу. Подключить и ts-jest, и babel, проверять типы прямо в тестах, свободно смешивать require и import. На деле двойная трансформация замедляет прогон в разы, а ошибки становятся нечитаемыми - непонятно, чей это стектрейс, компилятора типов или транспайлера. Смешение форматов даёт худшее: модуль то грузится, то нет.
Правило простое: выберите один путь и будьте последовательны. Первая развилка - трансформер. У неё два разумных ответа, и различие между ними не в скорости на пустом месте, а в том, проверяются ли типы во время прогона тестов.
Первый ответ - ts-jest. Он удобен, когда нужна тесная интеграция с TypeScript: ts-jest компилирует тест через настоящий компилятор TypeScript и попутно проверяет типы, поэтому несоответствие типов уронит тест так же, как ошибка выполнения. Настраивают его через preset - команда config:init создаёт заготовку. Ключевое правило: не дублируйте transform руками, если preset уже задал компиляцию, иначе получите ту самую двойную трансформацию.
Второй ответ - Babel или @swc. Они не компилируют типы, а стирают их: стирание типов означает механическое удаление аннотаций без всякой проверки, что делает трансформацию очень быстрой. Расплата в том, что тесты перестают ловить ошибки типов - зелёный прогон ничего не говорит о типовой корректности. Поэтому type-check выносят в отдельную команду tsc --noEmit, которую гоняют рядом с тестами, обычно в том же CI-шаге.
Выбор сводится к приоритету. ts-jest даёт уверенность в типах внутри теста ценой скорости; Babel и @swc дают скорость ценой слепоты к типам, которую вы компенсируете отдельным tsc --noEmit. Для небольшого проекта с медленным CI второй путь ощутимо приятнее; там, где типы - главный контракт и хочется ловить их прямо в прогоне, оправдан первый. Плохой вариант один - взять оба.
Вторая развилка - модульный формат. CommonJS - это старый формат Node, где модули подключают через require и module.exports. ESM - это стандарт языка с import и export. Нативный ESM в Jest требует согласованности трёх вещей: package.json с полем type module или расширения файлов, трансформера, настроенного на выдачу ESM, и флага Node, поднимающего экспериментальную поддержку модулей в виртуальной машине. Стоит рассогласовать одно - и модуль перестанет грузиться.
Отдельная ловушка ESM - моки. Классический jest.mock рассчитан на CommonJS: вызов поднимается наверх файла до импортов, потому что require исполняется лениво. В нативном ESM импорты статически связываются раньше любого кода, и поднятие уже не спасает. Поэтому для ESM используют jest.unstable_mockModule и динамический import(), который тянет модуль после установки мока. Механически смешивать CommonJS-моки с ESM-импортами нельзя - типичный симптом: мок словно проигнорирован, потому что модуль связался раньше, чем мок встал на место.
npm i -D jest ts-jest @types/jest typescript
npx ts-jest config:initnpm i -D jest babel-jest @babel/core @babel/preset-env \
@babel/preset-typescript
// babel.config.cjs
module.exports = {
presets: [
['@babel/preset-env', { targets: { node: 'current' } }],
'@babel/preset-typescript',
],
}