Одно репо часто живёт в двух мирах сразу. Бэкенд исполняется в Node: есть process, Buffer, файловая система, нет и не должно быть браузера. Фронтенд рассчитывает на DOM - document, window, события, - которого в Node нет. testEnvironment в Jest задаёт этот мир на весь конфиг: либо node, либо jsdom (лёгкая эмуляция браузера поверх Node). Один плоский конфиг вынужден выбрать что-то одно для всего - и тем самым обслуживает только половину репозитория.
Первое, что приходит в голову, - завести два отдельных конфига и два скрипта: один прогон для сервера, другой для веба. Это работает, но раскалывает набор надвое. Два запуска вместо одного, два отчёта о покрытии, которые потом нечем свести, watch-режим, который надо запускать дважды, и CI, который жонглирует двумя командами. Чем больше в монорепо пакетов, тем сильнее эта раздробленность мешает.
projects решает это иначе: один прогон Jest держит несколько независимых конфигов сразу. В поле projects лежит массив, где каждый элемент - самостоятельный мини-конфиг со своим testEnvironment, своим testMatch (какие файлы считать тестами), своим setupFilesAfterEnv. Jest запускает их в одном проходе и помечает вывод по displayName, так что сразу видно, чей это тест - server или web. Одна команда, один общий отчёт, но внутри - разные среды.
Работает это потому, что каждый пакет получает ровно свой мир и не платит за чужой. Сервер с testEnvironment: 'node' не поднимает jsdom - а это заметное время на старте и лишние глобальные объекты, которых бэкенду не нужно. Веб с testEnvironment: 'jsdom' получает document и window по-настоящему и не наследует случайные предположения из Node - не рассчитывает исподволь на process или Buffer, которых в браузере не будет. Границы сред перестают протекать друг в друга.
В монорепо это масштабируется через общие умолчания. То, что одинаково для всех пакетов - трансформер TypeScript, moduleNameMapper для алиасов, базовые настройки, - выносят в preset (переиспользуемый пресет конфига), а в каждом project оставляют только различия: имя, среду, где искать тесты. Так добавление нового пакета - это несколько строк project поверх общего пресета, а не копия всего конфига, которая назавтра разъедется с остальными.
Цена в том, что часть настроек живёт только на верхнем уровне, а не внутри project. Сбор покрытия и его пороги, репортеры, watch-плагины конфигурируются глобально для всего прогона - их нельзя задать по-разному для каждого проекта так же свободно, как среду. Поэтому projects оправданы, когда пакеты реально отличаются средой или набором файлов; для одного приложения с единой средой это лишний слой.
Типичный провал виден сразу, стоит перепутать границы. Тест компонента, который дёргает screen и document, попал под project со средой node - и падает на первом же обращении к document, которого в Node нет. Обратный случай тоньше: в одном плоском jsdom-конфиге серверный по смыслу тест незаметно опирается на window, который jsdom любезно подставил, - в настоящем Node такой код падает, но набор этого не ловит, потому что среда была не та. projects делает эту границу явной, и оба класса ошибок исчезают.
const config = {
projects: [
{
displayName: 'server',
testEnvironment: 'node',
testMatch: ['<rootDir>/apps/api/**/*.test.ts'],
},
{
displayName: 'web',
testEnvironment: 'jsdom',
testMatch: ['<rootDir>/apps/web/**/*.test.tsx'],
setupFilesAfterEnv: ['<rootDir>/test/setup-dom.ts'],
},
],
}