Тестовые сьюты, которые масштабируются: контракты, таксономия флейков, бюджеты
У каждого яруса компонентов свой публичный контракт — тестируйте его, а не внутренности. Снапшоты всего DOM утверждают всё и потому ничего. Флейки делятся на тайминг, зависимость от порядка и общее состояние; retry маскирует все три. MSW: дефолты happy-path. Юнит-слой до 10 с.
Сьют дорос до 4 200 тестов и 38 минут CI с шансом 6%, что любой прогон покраснеет без всякой связи с кодом. Команда проголосовала за очевидную милость: retry: 2 в конфиге Vitest. Красные прогоны почти исчезли — 6% превратились в доли процента, — и на флейки перестали смотреть: третья попытка всегда спасала. Через три месяца чекаут уехал в прод с гонкой двойной отправки: два клика по Pay во время медленного ответа создавали два списания. Тест на это был. Он падал с перебоями шесть недель — примерно один прогон из трёх, в точности как флейк, — и ретраи всё это время тихо красили его в зелёный. Разбор инцидента насчитал 1 800 дублирующихся списаний, выходные на рефанды и одну фразу, попавшую на слайд: «Наш тест знал, а наш CI был сконфигурирован не слушать». Retry не чинит флейки — он переклассифицирует прерывистый сигнал в шум, а прерывистость — ровно то, как проявляются настоящие гонки. Фиксы, которые действительно масштабируют сьют, негламурны: контракты по ярусам, гигиена изоляции, таксономия флейков с владельцами и бюджеты времени, соблюдаемые как любой SLO.
Что тестировать: контракт на ярус
«Что нам тестировать?» получает точный ответ, как только вы называете публичный контракт каждого яруса — обещание, на которое полагаются потребители и за которым внутренности могут меняться свободно. Листья дизайн-системы (Button, Select, Modal): контракт — это вывод рендера для состояний пропсов плюс поведение взаимодействий — активация с клавиатуры, focus trap, aria-атрибуты; тестируйте здесь исчерпывающе, потому что каждый баг наследуют двенадцать команд. Фичевые компоненты (OrderForm, SearchPanel): контракт — пользовательский поток против API: заполнить, отправить, увидеть успех и провал; тестируйте через RTL плюс MSW, горсть потоков на компонент, а не тест на каждый проп. Страницы и роутинг: контракт — композиция: нужные фичевые компоненты монтируются с нужным скоупом данных; немного интеграционных тестов, потому что остальное покрывает E2E с большей точностью и большей ценой. А чистая логика (валидаторы, форматтеры, редьюсеры, ценовая математика) в компонентных тестах не живёт вовсе: извлеките её и тестируйте в голом Node, где тысяча кейсов стоит две секунды. Инверсия любого слоя сжигает бюджет: исчерпывающее покрытие пропсов на ярусе страниц рождает тысячи медленных хрупких тестов; покрытие только потоками на ярусе листьев отгружает сломанный focus trap двенадцати командам.
Антипаттерн с запахом, который ловится grep: тесты, утверждающие внутренности — значения состояния, счётчики вызовов моков внутренних модулей, имена CSS-классов. Такие проверки пиннят реализацию, падают на безобидных рефакторингах и проходят на настоящей поломке (класс на месте — лэйаут уничтожен). Каждая проверка должна формулироваться как нечто наблюдаемое пользователем или компонентом-потребителем.
400-строчный снапшот всего DOM формы OrderForm падает после намеренной правки текста. Ревьюер скользит взглядом по стене диффа, жмёт u для обновления, апрувит. Каков системный вывод?
Снапшоты честно — и таксономия флейков
Прежде чем потянуться за снапшотом, спросите себя: сможет ли коллега, читающий этот дифф через полгода, понять — правка корректна или просто другая? Этот вопрос отделяет полезные снапшоты от дорогостоящего шума.
Честное правило снапшотов: снапшот — это проверка, чьё ожидаемое значение сгенерировано, поэтому она ровно настолько хороша, насколько ревьюер способен оценить её дифф. Десятистрочный toMatchInlineSnapshot отформатированной ошибки или вычисленного конфига лежит в файле теста, читаемый и ревьюибельный. 400-строчный toMatchSnapshot всего DOM падает на каждом касании разметки, приучает команду жать «обновить», и через квартал сьют содержит сотни ожидаемых значений, которых никто никогда не читал. Если дифф нельзя оценить за тридцать секунд — здесь должны были быть три целевые проверки.
Флейки заслуживают той же точности. Три вида, три механизма, три лечения — и retry не адресует ни одно:
- Тайминг — проверки гоняются с асинхронной работой: бюджеты waitFor, подобранные под ноутбуки, пробиваются на CI-раннерах с 2 vCPU; проверки спиннера гоняются с быстрыми моками; пропущенный
awaitу user-event. Лечение: ждать результаты (findBy), подбирать бюджеты под среду, никогда не спать. Территория урока 02. - Зависимость от порядка — тест проходит в одиночку и падает после конкретного соседа: несброшенный оверрайд MSW, утёкшие фейковые часы, модульный кеш, переживающий файлы, записанный и неочищенный
localStorage. Лечение: гигиена вafterEach(resetHandlers, useRealTimers, очистка стораджей) плюс свежие пер-тестовые фабрики для всего стейтфулного. Обнаружение механическое: прогон с включённымsequence.shuffleвскрывает вид за ночь. - Общее состояние — параллельные воркеры сталкиваются на одном ресурсе: одна строка БД, один порт, один временный файл, наполовину применённый глобальный мок
Date. Лечение: неймспейсинг по воркерам или внутрипроцессные фейки; этот вид растёт с параллелизмом и появляется ровно тогда, когда вы ускоряете сьют.
Retry конвертирует все три из детерминированных целей для дебага в фоновую радиацию — а как показала гонка чекаута из Hook, настоящие прерывистые баги выглядят неотличимо от флейков, поэтому сьют с ретраями структурно неспособен рассказать вам о гонках. Взрослая альтернатива — карантинная полоса: помеченный тест выполняется, но не блокирует мержи, к нему прикреплены владелец и дедлайн, а карантинный список видим и конечен. Карантин признаёт, что сигнал сломан; retry делает вид, что нет.
Спек проходит в одиночном запуске, но падает всякий раз после settings.test.tsx — который оверрайдит хендлер MSW на 500 и не имеет afterEach. Классифицируйте флейк и назовите масштабируемое лечение.
Организация MSW и бюджет времени
Архитектура хендлеров следует одному правилу из логики шва урока 02: дефолты описывают здоровый API; отклонения живут рядом с тестами, которым они нужны. Каталог handlers/ с файлами по доменам (orders.ts, auth.ts) экспортирует happy-path-набор, который собирает setupServer; ответы с ошибками, пустые состояния и медленные ответы выражаются через server.use внутри конкретного теста. В момент, когда 500 попадает в общие дефолты, каждый посторонний тест наследует сломанный API — одна команда потратила два дня на «случайные» падения, оказавшиеся хендлером ошибочного кейса, закоммиченным коллегой в дефолтный набор. Общие фабрики данных (buildOrder(overrides)) держат полезные нагрузки хендлеров по форме схемы в одном месте, и переименование поля API становится правкой одного файла, а не археологией по 60 тестам.
Бюджеты делают всё это принуждаемым. Числа, которые держатся на практике: тест чистой логики выполняется сильно меньше миллисекунды; jsdom-тест компонента стоит 50–300 мс (доминирует построение DOM в jsdom, примерно в 10 раз дороже node-теста); поэтому здоровое деление — слой чистой логики до 10 секунд — достаточно быстрый, чтобы гонять на каждое сохранение, — и компонентный сьют в единицы минут на CI, распараллеленный по воркерам. Vitest по умолчанию выполняет файлы тестов в параллельных воркерах; пофайловая изоляция стоит примерно треть общего времени, и выключать её (isolate: false) — рычаг производительности, который можно дёргать только при герметичной гигиене выше, потому что он превращает каждую модульную протечку в межфайловый флейк. После примерно десяти минут — шардируйте по машинам CI. Относитесь к бюджету как к SLO с владельцем: при пробитии профилируйте сотню самых медленных тестов — обычные подозреваемые это компонентные тесты, которым следовало быть логическими, реальные таймеры, которые никто не подделал, и setup-файл, рендерящий пол-приложения на каждый спек.
▸Почему это работает
Почему retry ощущается таким дешёвым и стоит так дорого? Отмывание делает вероятность. При 6% флейков на уровне сьюта retry: 2 показывает красный примерно в 0,02% случаев — флейки «решены» одной строкой конфига. Но та же арифметика применяется к настоящим прерывистым багам: гонка, падающая в одном прогоне из трёх, проходит ворота из трёх попыток примерно в 70% выполнений теста — и баг вроде двойного списания из Hook мержится за день-два попыток. Retry неотличим от фильтра, удаляющего ровно те отказы, чей паттерн — «иногда», а «иногда» — это подпись гонок, протечек и коллизий общего состояния, самого высокосерьёзного класса, который сьют способен ловить. Карантин — честная версия той же милости: он тоже разблокирует мержи, но держит отказ видимым, владеемым и конечным, а не статистически стёртым.
- 01Приведите таксономию флейков с механизмом и структурным лечением на вид и объясните, почему retry — неверный контроль для всех трёх.
- 02Опишите модель контрактов по ярусам и числа бюджетов, которые держат сьют ревьюибельным и быстрым.
Масштабирование тестового сьюта — архитектурная задача в костюме тулинга. Несущее решение — назвать публичный контракт каждого яруса и отказаться тестировать что-либо ещё: листья дизайн-системы зарабатывают исчерпывающее покрытие пропсов, клавиатуры и aria, потому что каждый дефект наследуют двенадцать команд; фичевые компоненты — горсть пользовательских потоков против MSW; страницы — проверки композиции; а чистая логика извлекается в голые node-тесты, где тысяча кейсов стоит две секунды. Проверки формулируют то, что наблюдает пользователь или компонент-потребитель — проверки, пиннящие внутренности, падают на безобидных рефакторингах и проходят на настоящей поломке: худшее из обоих миров. Снапшоты подчиняются границе ревьюибельности: проверка со сгенерированным ожидаемым значением сильна ровно настолько, насколько ревьюер способен судить её дифф, поэтому десятистрочные инлайн-снапшоты отформатированных ошибок — настоящие проверки, а 400-строчные дампы DOM тренируют рефлекс обновления, провозящий регрессии. Флейки — не погода; это три механизма. Таймин-флейки гоняются с асинхронной работой и сдаются ожидаемым результатам и бюджетам по среде. Зависимость от порядка — утёкшее состояние: несброшенные оверрайды MSW, замороженные часы, модульные кеши — убивается гигиеной afterEach и свежими фабриками, обнаруживается перемешиванием порядка. Флейки общего состояния — коллизии параллельных воркеров на реальных ресурсах, и приходят они ровно при распараллеливании. Retry не чинит ни один механизм; он статистически стирает симптом, а поскольку настоящие гонки тоже проявляются прерывисто, сьют с ретраями сконфигурирован их не слышать — 1 800 дублирующихся списаний из Hook были тестом, который знал, и третьей попыткой, которая его заглушила. Карантин с владельцами и дедлайнами — честная милость. Организация MSW зеркалит логику шва: happy-path-дефолты из пофайловых доменных хендлеров, каждое отклонение — через server.use внутри нуждающегося теста, фабрики данных держат нагрузки верными схеме в одном месте. И бюджеты — это SLO: слой логики до десяти секунд, компонентный сьют в единицах минут по воркерам, шардирование после десяти, профилирование сотни самых медленных при пробитии. Ничто из этого не гламурно; всё это накапливается. Теперь, когда видите retry: 2 в конфиге Vitest, читайте это как решение о подавлении флейков, которое может заодно подавлять настоящую гонку — и спрашивайте, к какому виду флейков оно относится, прежде чем принять конфиг как данность.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.