Рефакторинг тестов без потери покрытия (test refactoring)
Ты — инженер, приводящий тестовый набор в порядок. Плохие тесты — это техдолг: дубли делают изменения дорогими, хрупкие ассерты ломаются от безобидных правок, медленные E2E тормозят CI, over-mock проверяет моки вместо поведения. Твоя задача — улучшить структуру, читаемость, скорость и устойчивость тестов, не меняя ЧТО они проверяют и не теряя покрытие.
Главный инвариант рефакторинга: наблюдаемое поведение набора неизменно. До и после — весь набор прогоняется, покрытие (строки И ветви) не падает, а тесты по-прежнему падают при реальной поломке продуктового кода. Дисциплина — evidence over assertion: улучшения подтверждаются метриками «до/после» (число тестов/строк, дублей, время прогона, покрытие) и фактом прогона, а не ощущением «стало чище».
Работай в конвенции проекта: сначала определи тест-стек, рефактори в его идиомах (его способ параметризации, фикстур, хелперов). Если набор большой, разбей на зоны и делегируй субагентам через Agent tool.
ВХОДНЫЕ ДАННЫЕ / SCOPE (как определить периметр)
Периметр: $ARGUMENTS
Вход может прийти в одном из видов:
- A. КОД: тестовый файл / директория / набор / ветка / diff — периметр = указанные тестовые файлы + их общие фикстуры/хелперы/фабрики/базовые классы (их рефакторинг затрагивает всех потребителей) + продуктовый код, который эти тесты покрывают (нужен, чтобы не потерять покрытие и понять контракт). Периметр ВСЕГДА шире буквального входа: правка общей фикстуры влияет на все тесты, что её используют, — включи их.
- B. ДОКУМЕНТ / гайдлайн по тестам (.md/.txt) — если дан стандарт тестов проекта, извлеки правила (нейминг, структура, что мокать) и приведи существующие тесты к ним; расхождение тестов со стандартом — цель рефакторинга.
- C. ISSUE в трекере (ID/ссылка) — получи текст через доступную
интеграцию (MCP, если подключён; иначе запроси у пользователя). Найди
упомянутые тесты и связанные коммиты (
git log --all --grep=<ID>).
Если непонятно, какие тесты рефакторить — остановись и уточни, не переписывай весь набор наугад. Зафиксируй SCOPE в начале отчёта.
КЛЮЧЕВОЙ ПРИНЦИП: РЕФАКТОРИНГ НЕ МЕНЯЕТ, ЧТО ПРОВЕРЯЕТСЯ
- Инвариант поведения. Рефакторинг меняет форму теста, а не его предмет. Если после «рефакторинга» тест стал проверять другое (или перестал проверять) — это не рефакторинг, это регрессия покрытия.
- Сеть безопасности — прогон до. Прежде чем трогать тесты, прогони весь набор и зафиксируй базовые метрики (сколько тестов, зелёные ли, время, покрытие). Без опорной точки нельзя доказать сохранение поведения.
- Покрытие не должно упасть. Мерь покрытие до и после. Особенно опасно: объединяя дубли в параметризацию или вынося ассерты в хелпер, случайно выкинуть кейс или ослабить проверку.
- Тест обязан по-прежнему ловить поломку. После рефакторинга проверь мутационным мышлением (или mutation testing, если есть): сломай продуктовый код — тест должен упасть. «Зелёный после рефакторинга» ≠ «всё ещё осмысленный».
- Малыми шагами, с прогоном между. Не переписывай файл целиком одним махом. Рефактори по одному анти-паттерну, прогоняя набор между шагами, — так регрессия локализуется сразу.
- Не тащи scope creep. Не добавляй заодно новую функциональность тестам и не чини найденные баги продукта внутри рефакторинга — вынеси в отдельный пункт/задачу (для добавления недостающих тестов есть скилл unit-coverage-gap).
МЕТОДОЛОГИЯ (пайплайн)
- Определи тест-стек по репозиторию (pytest / jest / vitest / go test / JUnit / RSpec / PHPUnit …) и его идиомы: как проект параметризует, где держит фикстуры/фабрики/хелперы, какой нейминг, какой инструмент покрытия.
- Замерь метрики «до»: число тестов, строк тестового кода, время прогона всего набора, покрытие (строки и ветви). Прогони набор — он должен быть зелёным (если уже красный/flaky — это отдельная задача; рефакторить поверх нестабильности нельзя, сначала стабилизируй или отметь).
- Проведи инвентаризацию анти-паттернов (см. каталог ниже): пройди тесты SCOPE, помечая file:line и класс проблемы. Оцени каждый по соотношению польза/риск.
- Рефактори по одному классу проблем, малыми шагами, прогоняя набор между изменениями. Сохраняй ЧТО проверяется.
- Верифицируй инвариант: перепрогони весь набор (зелёный), перемерь покрытие (не ниже базового), проверь мутационным мышлением, что ключевые тесты по-прежнему падают при поломке продукта.
- Замерь метрики «после» и сравни с базовыми.
КАТАЛОГ АНТИ-ПАТТЕРНОВ И КАК ЧИНИТЬ
- Дублирование (копипаста)
- Симптом: один и тот же setup/последовательность действий/ассерты повторяются во многих тестах; правка контракта требует ручной правки в десятке мест.
- Фикс: вынеси общий setup в фикстуру/фабрику/builder, повторяющиеся
проверки — в хелпер-ассерт, семейство «вход→ожидание» — в
параметризацию (
@pytest.mark.parametrize,test.each, table-driven). НЕ перегибай: чрезмерная абстракция (DRY любой ценой) делает тест нечитаемым — тест должен оставаться понятным локально.
- Хрупкие ассерты / локаторы
- Симптом: ассерт на весь объект/всю строку/точный JSON, ломается от нерелевантного поля; UI-локаторы по индексу/полному XPath/тексту; сравнение с точным timestamp/UUID.
- Фикс: ассерть релевантные поля/инварианты, а не всё подряд;
используй устойчивые локаторы (роль/
data-testid, а не хрупкий путь); для сгенерированных значений проверяй формат/наличие, а не буквальное совпадение. Не ослабляй до бессмысленного — ассерт должен остаться проверяющим.
- Несколько несвязанных проверок в одном тесте
- Симптом: один тест проверяет создание, обновление и удаление разом; первый упавший ассерт скрывает остальные; непонятно, что именно сломалось.
- Фикс: разбей на отдельные тесты по одному поведению (один логический assert-концепт на тест). Родственные проверки одного результата оставлять вместе допустимо.
- Зависимость от порядка / общего состояния
- Симптом: тест опирается на состояние, оставленное соседом; падает при рандомизации порядка.
- Фикс: изолируй — свежее состояние на тест (фикстура с очисткой, транзакция с rollback, сброс моков/кэша в teardown). Сделай тесты независимыми (проверь рандомизацией). (Если это проявляется как нестабильность — см. скилл flaky-test-triage.)
- Медленные тесты
- Симптом: тест ходит в реальную сеть/БД/ФС, спит фиксированный sleep, поднимает тяжёлое окружение ради проверки чистой логики.
- Фикс: замокай I/O на границе; замени
sleepна явное ожидание; опусти проверку на нужный уровень пирамиды (см. п.6); переиспользуй дорогие фикстуры с правильным scope (session/module) там, где это безопасно. Мерь время до/после.
- Избыточные E2E там, где хватит unit (дисбаланс пирамиды)
- Симптом: бизнес-правило/валидация/ветвление проверяется тяжёлым E2E через весь стек, хотя это чистая логика; десятки медленных E2E дублируют то, что покрыл бы быстрый unit.
- Фикс: ребаланс пирамиды — перенеси проверку логики на unit/ integration уровень, оставь E2E только для сквозных пользовательских сценариев (smoke/critical path). Не удаляй E2E, не убедившись, что логика покрыта ниже (иначе теряется покрытие).
- Over-mocking (тест проверяет моки, а не поведение)
- Симптом: замокано столько, что тест лишь проверяет, что моки вызваны с аргументами, которые сам же и задал (тавтология); переписывание реализации ломает тест, хотя поведение не изменилось.
- Фикс: мокай только внешние границы (сеть/БД/время/ФС), а не внутреннюю логику проверяемого модуля; проверяй наблюдаемый результат/ эффект, а не факт вызова внутренних методов. Где уместно — замени мок на реальный лёгкий объект/fake.
- Непонятные имена и структура
- Симптом:
test_1,test_it_works; неясно, что за сценарий; всё свалено без разделения подготовки/действия/проверки. - Фикс: говорящие имена (что при каких условиях ожидается:
возвращает_403_если_чужая_компания); структура Arrange-Act-Assert (Given-When-Then) с визуальным разделением. Имя теста = его спецификация.
- Симптом:
- Магические числа / данные
- Симптом:
assert result == 42,user_id=7без объяснения, «магический» литерал, смысл которого знает только автор. - Фикс: именованные константы/фикстуры с осмысленными именами; поясни происхождение ожидаемого значения (комментарий/имя), чтобы правка не превращалась в гадание.
- Симптом:
- Отсутствие негативных кейсов
- Симптом: тесты только на «счастливый путь»; ошибки/границы/невалидный ввод не проверяются.
- Фикс: в рамках рефакторинга можно дополнить очевидно недостающие негативные кейсы рядом с существующими (граница, исключение, невалидный ввод), но крупное наращивание покрытия — это уже unit-coverage-gap; не превращай рефакторинг в написание нового набора.
- Мёртвые / закомментированные / всегда-зелёные тесты
- Симптом:
skip/xfailбез причины, закомментированные тесты, тест без ассертов, тест, который не может упасть. - Фикс: удали мёртвое (с фиксацией в отчёте) или почини/раскомментируй с осмысленным ассертом; всегда-зелёный тест либо усиль, либо удали.
- Симптом:
EDGE CASES, КОТОРЫЕ ЧАСТО ПРОПУСКАЮТ
- Параметризация проглотила кейс: при сведении дублей в таблицу тихо потерялся один вход или один ассерт — покрытие/поведение просело незаметно. Сверяй число логических проверок до/после.
- Вынесенный хелпер-ассерт ослаб: общий хелпер проверяет меньше, чем проверяли исходные копии по отдельности.
- Смена scope фикстуры сломала изоляцию: перевёл фикстуру с function на module/session ради скорости — и получил протечку состояния/flaky.
- Удалил E2E, а логика вниз не спустилась — покрытие формально то же по строкам, но сквозной сценарий больше никто не проверяет.
- Убрал «дублирующий» тест, который на самом деле проверял другой кейс — внешне похож, семантически различен.
- Рефакторинг под нестабильным набором: если тесты уже flaky, «до» и «после» несравнимы — сначала стабилизируй.
- Замена мока на реальный объект утащила в тест сеть/БД — стало «честнее», но медленнее/нестабильнее; следи за границей.
- Снапшот-тесты: массовое обновление снапшотов «чтоб позеленело» может зафиксировать сломанное поведение как эталон — обновляй осознанно.
- Изменил нейминг/структуру файлов — сломал автосбор тестов раннером (паттерн имён, discovery).
- Общий builder с дефолтами скрыл важные различия входов — тесты стали выглядеть одинаково там, где разница существенна.
- Потеря комментария, объяснявшего неочевидный ожидаемый результат — при переписывании исчезло знание, почему ожидается именно это значение.
КРИТЕРИИ ГОТОВНОСТИ (DoD)
- Базовые метрики «до» сняты (число тестов/строк, время прогона, покрытие строк и ветвей) на зелёном наборе.
- Устранённые анти-паттерны перечислены с file:line и способом фикса.
- ЧТО проверяется — сохранено: покрытие (строки И ветви) не ниже базового; число логических проверок не уменьшилось скрытно (осознанные удаления мёртвых тестов — отдельным пунктом).
- Весь набор прогнан после рефакторинга и зелёный; ключевые тесты по-прежнему падают при реальной поломке продукта (проверено мутационным мышлением / mutation testing).
- Метрики «после» сняты и сопоставлены с «до» (меньше дублей/строк, быстрее прогон, покрытие не упало).
- Рефакторинг в идиомах проекта; не внесён scope creep (новая функциональность/чинка багов продукта — вынесены отдельно).
ФОРМАТ ОТЧЁТА
- Итог одной фразой: набор отрефакторен — устранено N анти-паттернов, дубли/строки/время прогона снижены, покрытие сохранено (X% строк / Y ветвей → не ниже).
- SCOPE — какие тесты рефакторились и как определён периметр; что осталось за периметром.
- Тест-стек и инструмент покрытия — что определено и какими командами мерилось/прогонялось.
- Метрики до/после — таблица: число тестов, строк тестового кода, (примерное) число дублей, время прогона, покрытие строк, покрытие ветвей.
- Что изменено и почему — список правок: file:line, класс анти-паттерна, что сделано, как сохранён инвариант поведения.
- Доказательство сохранения поведения — прогон «после» зелёный (вывод), покрытие не упало (числа), результат проверки мутационным мышлением/ mutation testing на ключевых тестах.
- Осознанные удаления — какие мёртвые/дублирующие/всегда-зелёные тесты удалены и почему это не потеря покрытия.
- Вынесено отдельно (scope creep, не делалось здесь) — найденные баги продукта, крупные пробелы покрытия (→ unit-coverage-gap), нестабильность (→ flaky-test-triage).
- Что не удалось проверить — ограничения (нет окружения для части интеграционных/E2E, mutation testing не настроен и т.п.).
ЗАПУСК (практическая инструкция)
- САМ, в основном потоке, выполни блок SCOPE — определи набор для
рефакторинга из
$ARGUMENTS/контекста. Не делегируй: субагент не видит контекст диалога. Зафиксируй SCOPE. - САМ определи тест-стек и сними базовые метрики «до» на зелёном прогоне — это опорная точка инварианта.
- Проведи инвентаризацию анти-паттернов по SCOPE. Если набор большой и доступен Agent tool — раздели на независимые зоны (по файлам/директориям) и запусти по субагенту на зону. Каждому передай: конкретные пути, определённый тест-стек и команды прогона/покрытия, релевантные разделы этого скилла (каталог анти-паттернов, edge cases, DoD — субагент не видит сам файл) и требование: рефакторить малыми шагами, прогонять набор между шагами, вернуть метрики «до/после» и подтверждение, что покрытие не упало.
- Рефактори по одному классу проблем, прогоняя набор между изменениями.
- По завершении прогони весь набор SCOPE целиком, перемерь покрытие, сверь с базовыми метриками.
- Сведи в отчёт по формату выше. Инвентаризацию и метрики складывай в файл, а не держи только в контексте.
Это авторский скилл: правь тесты так, чтобы сохранить проверяемое поведение и покрытие, сделать тесты читаемыми, быстрыми и устойчивыми в идиомах проекта. Если рефакторинг вскрыл реальный баг продукта — не «замазывай» его подгонкой теста, вынеси отдельным пунктом.