Предыстория

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

Начал смотреть тесты в проекте и увидел системные проблемы. Не точечные баги, а повторяющиеся паттерны, из-за которых тесты не выполняли свою главную функцию — давать уверенность, что код работает правильно.

Решил не просто чинить отдельные тесты, а поменять подход целиком. Подготовил материал, собрал команду и провёл митап на полтора часа.


Три проблемы, которые я увидел

1. Снэпшоты вместо осмысленных проверок

Типичный тест в проекте выглядел так:

describe("UserProfileCards", () => {
    it("should render", () => {
        const wrapper = shallow(
            <Provider store={store}>
                <UserProfileCards items={mockedUserProfiles} />
            </Provider>
        )
        expect(wrapper).toMatchSnapshot()
    })
})

Что здесь не так? Снэпшот проверяет разметку, а не поведение. Любой рефакторинг (переименовал CSS-класс, поменял порядок атрибутов) ломает снэпшот, хотя компонент работает как раньше. А настоящий баг в логике снэпшот может пропустить.

2. Привязка к деталям реализации

Вот реальный пример из кодовой базы — компонент-стрелочка, которая складывается вверх или вниз:

import CollapseIcon from "./CollapseIcon"
import { shallow } from "enzyme"

describe("CollapseIcon", () => {
    it("should render", () => {
        const wrapper = shallow(<CollapseIcon opened={false} />)
        expect(wrapper).toMatchSnapshot()
    })

    it("should react to closed", () => {
        const wrapper = shallow(<CollapseIcon opened={false} />)
        expect(wrapper.find(".collapse__icon").hasClass("collapseIcon_closed")).toEqual(true)
    })

    it("should react to opened", () => {
        const wrapper = shallow(<CollapseIcon opened={true} />)
        expect(wrapper.find(".collapse__icon").hasClass("collapseIcon_opened")).toEqual(true)
    })
})

Три проблемы в трёх тестах:

  • Снэпшот не ловит баг в логике раскрытия/закрытия

  • Второй и третий тесты цепляются за CSS-классы — если переименуешь класс, тест падает. Если поломаешь саму анимацию, тест пройдёт

  • Это ложно отрицательные (падают при рефакторинге) и ложно положительные (не ловят баги) тесты одновременно

Ещё одна проблема — привязка к внутренним константам:

it("should render no-answers button", () => {
    const button = getButton(mockCommentsList[0])
    const noAnswersText = `${mockedTranslation.t(`${tNamespace}no-answers`)}`
    expect(button.text()).toBe(noAnswersText)
})

Тест импортирует tNamespace и mockedTranslation из внутренностей компонента. Поменяешь ключ локализации — тест упадёт, хотя кнопка по-прежнему показывает правильный текст.

Решение: один источник истины. Все константы выносятся в сборную константу, которую импортируют и компонент, и тест. При рефакторинге тест автоматически подхватывает изменения.

3. Нет ясной цели у тестов

«should render» — это не цель. Это констатация факта, что компонент не упал при рендере. Но что конкретно он должен делать?

Плохие описания из реального кода:

  • «should show tooltip» — при каких условиях? Что именно проверяем?

  • «should render without tooltipLabel» — и что должно произойти?

  • «should show tooltip» — дубликат первого описания

Хорошие описания строятся по принципу: «This test checks that the component...»:

  • "displays tooltip when isTooltipShow is true"

  • "renders without tooltip when tooltipLabel is undefined"

  • "displays correct tooltip content when isTooltipShow is true"


Решение: React Testing Library

Почему не починка Enzyme, а замена

Enzyme даёт прямой доступ к внутренностям компонента: state, instance, methods. Это провоцирует тестирование деталей реализации. React Testing Library не даёт этого делать by design.

Ключевой принцип RTL: тесты должны отражать, как пользователи взаимодействуют с приложением. Чем ближе тест к реальному использованию, тем больше уверенности он даёт.

Что даёт RTL

  • Пользовательский подход: взаимодействуем с компонентом так, как это делает пользователь (клик, ввод, чтение текста)

  • Устойчивость к рефакторингу: нет привязки к CSS-классам, именам переменных, внутреннему state

  • Простой API: render, screen, fireEvent, waitFor

  • Асинхронность из коробки: findBy и waitFor для компонентов с загрузкой данных

  • Доступность: тесты через getByRole, getByLabelText проверяют, что интерфейс доступен


Как я провёл митап (и что бы сделал иначе)

Подготовка

Собрал проблемные тесты из нашей кодовой базы, структурировал в презентацию на 21 слайд: теория → проблемы (на наших примерах) → решение → чек-листы. Добавил мемы для разгрузки.

Что получилось

  • Полтора часа, формат доклада для всей команды

  • Прошёлся по полной программе: от пирамиды тестирования до конкретных чек-листов

  • Обратная связь: материал полезный

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

Самый важный урок: получился доклад, а не диалог. Я так увлёкся материалом, что не оставил места для обсуждения. Команда слушала, но не участвовала.

Если бы проводил заново:

  • Две сессии по 45 минут с перерывом

  • После каждой части — авторизация: "что откликнулось? что не понятно?"

  • Меньше теории, больше совместного разбора реальных тестов

  • Пара-программинг: вместе переписываем один тест из кодовой базы


Чек-листы

Для написания тестов

  1. Определена цель: что конкретно проверяю?

  2. Пользовательская ориентация: тест с точки зрения пользователя, а не разработчика

  3. Изоляция: тест не зависит от других тестов и внешних сервисов

  4. Чёткое описание: читается как предложение — "проверяет, что компонент..."

  5. Покрытие: основные сценарии + граничные условия + обработка ошибок

Для ревью тестов

  1. Ясность цели: понятно, зачем этот тест существует?

  2. Пользовательская перспектива: нет ли привязки к деталям реализации?

  3. Изоляция: тест можно запустить отдельно?

  4. Качество описаний: описания информативные?

  5. Обратная связь: комментарии конструктивные?


Итого

Переход с Enzyme на React Testing Library — это не замена одной библиотеки на другую. Это смена мышления: от «тестирую внутренности компонента» к «тестирую то, что видит пользователь». Пока команда не понимает, зачем она пишет тесты, никакой инструмент не поможет — ни Enzyme, ни RTL, ни LLM-пайплайн для автоматической миграции.

Начать можно с малого. Один чек-лист. Одно правило: тестируем поведение, не реализацию. И один митап для команды — пусть даже он получится не идеальным. Мой получился докладом, а не диалогом. Но он запустил процесс, который оказался важнее любой презентации.


А что с миграцией?

Когда готовил эту статью, наткнулся на кейс Airbnb. В 2024 году они мигрировали 3500 файлов с Enzyme на RTL с помощью LLM-пайплайна. 75% файлов за 4 часа, 97% за неделю, весь проект за 6 недель вместо оценочных полутора лет ручной работы. Подробности

Масштабы у нас разные. У них 3500 файлов и выделенная команда. У нас компания поменьше и разработчик, который решил, что пора. Но проблема та же: новые тесты пишем на RTL, а старые на Enzyme никуда не делись.

Философию я описал выше: сначала понять, зачем тестируем и что проверяем. Но одного понимания мало. Поэтому сейчас я занимаюсь именно миграцией: мультиагентный пайплайн, LLM, автоматизация, текущие инструменты 2026 года. О проблемах, подходах и решениях расскажу в следующей статье.