600 тестов с Enzyme на RTL: как я построил AI-конвейер и не сошёл с ума

Enzyme мёртв. Адаптера для React 18 нет и не будет. Я стоял перед фронтенд-приложением с примерно 600 тестами на Enzyme и двумя вариантами: месяцы ручной работы или эксперимент с Claude Code, который мог провалиться на первой же сотне файлов.

Я выбрал второй вариант. И вот что из этого вышло.

Почему Enzyme пора хоронить (и почему это больно)

Enzyme создан Airbnb в 2015 году. Последний официальный адаптер - React 16. Для React 17 существовал неофициальный адаптер от Wojciech Maj, и автор этого адаптера сам призвал от Enzyme отказаться. Для React 18 и 19 - ничего. Concurrent rendering, Suspense, хуки - всё это Enzyme не поддерживает и не будет поддерживать.

При этом Enzyme до сих пор скачивают полтора миллиона раз в неделю на npm. Масштаб проблемы гигантский. Компании годами откладывают миграцию, потому что “работает же”. Работает. Пока не обновишь React.

Проблема не только техническая. Enzyme и RTL - это две разных философии тестирования. Enzyme позволяет лезть внутрь компонента: читать state, дёргать instance, искать дочерние компоненты по имени класса. RTL говорит: забудь про внутренности, тестируй то, что видит пользователь.

EnzymeRTL
ФилософияТестирование реализацииТестирование поведения
Рендерингshallow(), mount(), render()Только render() - полный DOM
Доступ к элементамjQuery-подобные: find(), children()Семантические: getByRole(), getByText()
Внутренностиstate(), instance(), props()Нет доступа - только DOM
Событияsimulate(‘click’)userEvent.click()

Kent C. Dodds, автор RTL, сформулировал принцип: “Чем больше тесты похожи на реальное использование, тем больше уверенности они дают.” Звучит красиво. На практике это значит, что у тебя нет прямого аналога для половины Enzyme API. И каждый тест нужно не просто переписать - нужно переосмыслить.

У меня в проекте было около 600 тестов. Некоторые - простые, вида “отрендери компонент, проверь текст”. Другие - многоуровневые монстры с shallow rendering, прямым чтением state и цепочками find().children().at(2).props(). Обновление React стало неизбежным, а значит, миграция тоже.

Наивный подход: “AI, обнови всё”

Спойлер: не работает.

У Elio Capella из Filestage было 6000 тестов на Enzyme. Он попробовал дать AI задачу “обнови все тесты”. Результат - провал. AI механически заменял API-вызовы, но терял смысл тестов. Тест вроде зелёный, а проверяет уже не то, что нужно.

Я наступил на те же грабли. Первые попытки выглядели так: берёшь файл с тестами, скармливаешь Claude Code, получаешь результат. Запускаешь - половина тестов красная. Чинишь. Запускаешь - другая половина красная. Чинишь ту - ломается первая. Через пару часов такого цикла становится понятно: нужна система.

Проблемы две. Первая - AI хорошо справляется с механической заменой. find(’.button’) на getByRole(‘button’) - пожалуйста. simulate(‘click’) на userEvent.click() - легко. Но когда тест проверяет конкретное бизнес-правило через wrapper.state(), AI может переписать его так, что формально всё зелёное, а реальная проверка потеряна.

Вторая проблема - избыточный mocking. Команда Mattermost это тоже заметила на своём PR #30528: AI-генерированные тесты часто мокают слишком много. Тест проходит, но мокает половину приложения, и уверенности в коде он даёт ноль.

Архитектура конвейера: 10 фаз, субагенты, параллелизм

После нескольких неудачных попыток я сел и спроектировал pipeline. Не “дай AI файл и молись”, а нормальный конвейер с фазами, контролем и обратной связью.

Получилось 10 фаз. Каждая фаза - группа файлов, объединённых по какому-то признаку: модуль приложения, сложность, тип тестов. Внутри каждой фазы - подфазы по 3 файла. Почему три? Это баланс. Меньше - слишком медленно. Больше - теряется контроль, и если что-то ломается, непонятно где.

На каждую подфазу работали три параллельных субагента:

  • implementer - пишет код миграции
  • spec-reviewer - проверяет, что тесты после миграции тестируют то же самое
  • code-reviewer - проверяет качество кода, ищет избыточный mocking и антипаттерны

Три субагента - не потому что я прочитал какой-то умный гайд про “мультиагентные системы”. Просто после десятка итераций стало понятно: один агент пишет код и сам же его одобряет. Нужен кто-то, кто посмотрит свежим взглядом. Да, в Claude Code субагент - это легковесный экземпляр через Task Tool, а не отдельный сервер. Но разделение ответственности работает.

К этой архитектуре я пришёл не сразу. Было примерно 156 промптов за весь процесс. Первые десятки ушли на эксперименты: какой контекст давать, как формулировать задачу, что включать в guidelines. Pipeline менялся раз за разом. Первая версия была линейной - файл за файлом, последовательно. Потом добавил параллелизм. Потом ревьюеров. Потом retry loop.

Кстати про контекст. Airbnb при миграции своих 3500 файлов обнаружил интересную вещь: “Choosing the right related files was more important than getting prompt engineering perfect.” Выбор правильных файлов для контекста оказался важнее идеального промпта. Я пришёл к тому же выводу. Когда я стал давать субагентам не только тестовый файл, но и исходный компонент, и примеры уже мигрированных тестов - качество выросло радикально.

Coverage как страховка: контроль до и после

Вот принцип, который спас мне кучу нервов: coverage не должен упасть после миграции. Кажется очевидным, но без автоматической проверки это забывается на третьем файле.

Я встроил контроль покрытия прямо в pipeline. Перед миграцией файла - замер coverage. После миграции - ещё один замер. Если coverage упал - файл уходит в retry loop: субагенту возвращаются ошибки и отчёт о покрытии, он переделывает.

Airbnb делал похожую вещь, только в другом масштабе. Их подход “sample, tune, sweep”: берёшь выборку проблемных файлов, дорабатываешь промпты на них, потом прогоняешь весь пул. У меня масштаб скромнее, но идея та же - итеративное улучшение.

Coverage ловил вещи, которые зелёные тесты пропускали. Тест может быть зелёным, потому что он мокает модуль и проверяет, что мок вызвался. Формально - passed. А coverage показывает: реальный код компонента вообще не выполнялся. Вот тебе и “проходящий тест”.

Были ситуации, когда AI переписывал тест, он проходил, но coverage определённой ветки кода исчезал. Без автоматической проверки я бы это не заметил - файлов сотни, вручную каждый не проверишь.

Паттерны, которые ломают AI (и как их чинить)

Некоторые Enzyme-паттерны конвертируются в RTL тривиально. mount() в render(), find(’.title’).text() в getByText(). Но есть паттерны, у которых нет прямого аналога, и вот с ними начинается самое интересное.

shallow() - самый болезненный. Enzyme-разработчики любили shallow rendering за изоляцию: рендеришь только сам компонент, дочерние не трогаешь. В RTL такого нет. Есть render() - и он рендерит всё дерево. Решение - мокать дочерние компоненты:

// Enzyme
const wrapper = shallow(<ParentComponent />);
expect(wrapper.find('ChildComponent')).toHaveLength(1);

// RTL
jest.mock('./ChildComponent', () => () => <div data-testid="child" />);
render(<ParentComponent />);
expect(screen.getByTestId('child')).toBeInTheDocument();

Работает, но каждый такой мок - это ещё один кусок кода, который нужно поддерживать. И AI иногда увлекается, мокая вообще всё подряд.

wrapper.state() и wrapper.instance() - прямого доступа к state в RTL нет. И не должно быть, по философии RTL. Если компонент хранит count в state - проверяй текст “Count: 5” на экране, а не значение переменной. На практике это значит, что некоторые тесты нужно переосмыслить целиком, а не просто заменить API-вызов.

wrapper.setProps() заменяется на rerender(), но семантика другая. В Enzyme ты мог обновить один prop. В RTL нужно перерендерить весь компонент с новыми пропсами.

RTL предлагает чёткий приоритет запросов: getByRole на первом месте, потом getByLabelText для форм, getByText для не-интерактивных элементов. И только в крайнем случае - getByTestId. AI часто ленится и ставит testId везде. Ревьюер-субагент ловил такое и отправлял на переделку.

В моём проекте самыми проблемными оказались большие файлы с тестами, где shallow rendering переплетался с прямым чтением state и цепочками find(). Такой файл на сотни строк - это не “замени API”, это “пойми, что тут тестируется, и перепиши с нуля”. Субагенту приходилось делать несколько итераций, и даже после этого я проверял вручную.

Числа и честная статистика

Мой проект: около 600 тестов, 10 фаз миграции, примерно 156 промптов за весь процесс. Субагенты работали параллельно, подфазы по 3 файла.

Для контекста - как это выглядит в индустрии:

КомпанияМасштабАвтоматизацияВремя
Airbnb3,500 файлов97% автоматически6 недель
Slack15,000+ тестов80% (AST + LLM)~6 месяцев
HubSpot76,000 тестовПолностью ручная2.5 года
Mattermost60 файловClaude CodeЧасы

Airbnb - самый впечатляющий кейс. 97% файлов мигрировано автоматически, 75% - за первые четыре часа, остальные 22% - за четыре дня тюнинга промптов. Вручную осталось 3%. Они оценивали ручную миграцию в полтора года. Автоматизация сократила это до шести недель.

Slack пошёл гибридным путём: jscodeshift для частых паттернов (типа замены find и simulate - тысячи вхождений), а LLM подключали для сложных случаев с контекстом. Их тулзу можно найти на npm: @slack/enzyme-to-rtl-codemod.

HubSpot - контраст. 76 тысяч тестов, 2.5 года, полностью вручную. Пик - 4000 тестов в месяц, сотни репозиториев. Сами потом написали: “How we might approach this work differently in the age of AI.”

Mattermost ближе к моему масштабу - 60 файлов через Claude Code, за часы. Создали CLAUDE.md с project guidelines, разбили большой PR на мелкие для ревью.

Мой подход где-то между Mattermost и Airbnb по степени проработки pipeline. Не просто “дай Claude Code файл”, но и не промышленный конвейер на тысячи файлов. Для 600 тестов - адекватный уровень.

Что я сделал бы иначе

Несколько вещей, которые стали понятны постфактум.

Персоны субагентов - скорее театр. На Hacker News об этом писали прямо: назначение ролей вроде “ты senior engineer” или “ты QA expert” - это theater more than utility. Содержание промпта важнее роли. У меня субагенты различались не “персонами”, а задачами: один пишет код, другой проверяет спецификации, третий проверяет качество. Разделение ответственности работает. Театральные роли - нет.

Контекст решает. Это самый главный урок. Airbnb давал LLM промпты на 40-100 тысяч токенов, включая до 50 связанных файлов. Slack извлекал реальный DOM каждого тестового случая и передавал вместе с кодом. Один LLM без контекста даёт 40-60% success rate. С правильным контекстом - 80% и выше. Я бы с самого начала вкладывался в подготовку контекста, а не в шлифовку промптов.

Человеческая верификация обязательна. Даже при 97% автоматизации Airbnb оставшиеся 3% требовали ручной работы. Но дело не только в них. Автоматически проходящий тест не гарантирует, что он тестирует правильное поведение. Я бы закладывал больше времени на ручной review с самого начала, а не на этапе “почему coverage упал”.

Отказ от shallow rendering - это не потеря. Поначалу казалось, что без shallow() тесты станут тяжелее и медленнее. Отчасти это правда - полный render с моками зависимостей сложнее в настройке. Но тесты стали реалистичнее. Они ловят баги на стыке компонентов, которые shallow просто не видел. Это не компромисс, это апгрейд.

Подфазы по 3 файла - хороший размер, но можно было варьировать. Для простых файлов - тройка избыточна, можно было брать больше. Для сложных - иногда и одного файла на подфазу было много. Динамическая группировка по сложности сэкономила бы время.

Чеклист: как повторить это у себя

Если у вас есть проект на Enzyme и вы готовы мигрировать, вот последовательность действий.

Подготовка:

  • Инвентаризация Enzyme-вызовов в проекте. Сколько файлов, какие паттерны используются (shallow, mount, state, instance, simulate). Можно grep-нуть по импортам из enzyme.
  • Создать CLAUDE.md (или аналогичный документ) с project guidelines: структура проекта, конвенции именования, используемые библиотеки для тестов.
  • Подготовить few-shot примеры: взять несколько файлов, мигрировать вручную, использовать как образцы для AI.
  • Замерить baseline coverage.

Pipeline:

  • Разбить файлы на фазы по модулям или сложности.
  • Настроить субагентов: как минимум implementer и reviewer. Можно через Task Tool в Claude Code.
  • Подфазы по 3-5 файлов - зависит от сложности.
  • Retry loop: если тесты красные или coverage упал - автоматический возврат субагенту с ошибками.
  • Coverage gate: сравнение покрытия до и после каждой подфазы.

Приоритет запросов RTL (заучить наизусть):

  1. getByRole - основной
  2. getByLabelText - для форм
  3. getByText - для текстовых элементов
  4. getByTestId - только в крайнем случае

Контекст для AI:

  • Исходный компонент (не только тесты)
  • Уже мигрированные примеры из вашего проекта
  • Migration guidelines
  • Импорты и зависимости

Ручная доработка:

  • Закладывайте время на ревью. Каждый файл. Да, каждый.
  • Особое внимание на файлы с shallow rendering и прямым доступом к state.
  • Проверяйте, что тест реально тестирует бизнес-логику, а не просто “рендерится без ошибок”.

Полезные ресурсы:

  • testing-library.com - официальная документация RTL
  • @slack/enzyme-to-rtl-codemod - AST-codemod от Slack
  • Статья Airbnb Engineering про их миграцию
  • PR Mattermost #30528 - пример миграции через Claude Code

Миграция 600 тестов с Enzyme на RTL - это не задача на выходные. Но с правильно выстроенным конвейером это и не задача на полгода. Claude Code с субагентами, coverage-контролем и итеративными промптами превращает месяцы ручной работы в управляемый процесс. Не волшебный, не полностью автоматический - но управляемый. А это, пожалуй, главное.