Новое в журнале. Маленькие pull request: почему их быстрее ревьюят и как резать задачу
Практика и проекты

Рефакторинг легаси без страха: тесты-характеристики и маленькие шаги

Старый код пугает не сложностью, а отсутствием уверенности в том, что он делает и что перестанет делать после правки. Три приёма снимают это напряжение.

23 июня 20269 мин чтенияПрактикам
Рефакторинг легаси без страха: тесты-характеристики и маленькие шаги
0 строкпереписано с нуля в strangler fig
15 минутна один цикл маленького шага

Легаси-код редко пугает своей сложностью. Пугает неизвестность: никто не помнит, зачем здесь эта проверка на null, кто и когда добавил костыль в середине метода на триста строк, и что случится у клиентов, если убрать «мёртвый», на первый взгляд, блок. Разработчик смотрит на такой код и откладывает правку на потом, а потом обвязывает его новым кодом сверху, не трогая внутренности. Со временем система превращается в слоёный пирог из обходов, и каждый следующий слой класть всё труднее. Ниже три приёма, которые снимают основное напряжение: характеризационные тесты, маленькие обратимые шаги и strangler fig для замены целых модулей без остановки системы.

Почему трогать старый код страшно именно этого разработчика

Страх перед легаси почти всегда связан не с кодом, а с отсутствием обратной связи. Если после правки можно за пару секунд узнать, что поведение системы не изменилось, менять код не страшнее, чем переименовать переменную. Проблема в том, что в старом коде обычно нет тестов, а значит нет и быстрой обратной связи: приходится вручную прокликивать интерфейс или вчитываться в логи продакшена, чтобы понять, не сломалось ли что-то. Это медленно, неприятно и провоцирует откладывать рефакторинг до тех пор, пока не станет совсем плохо.

Второй источник страха: неполное понимание, зачем код устроен именно так. В легаси часто встречаются решения, продиктованные давно забытым требованием клиента или обходом бага сторонней библиотеки трёхлетней давности. Удалить такую строчку выглядит безопасно, а на деле ломает редкий, но важный сценарий. Решение в обоих случаях одно: прежде чем менять поведение, нужно его зафиксировать.

Главная мысльРефакторинг без тестов это не рефакторинг, а редактирование наугад с надеждой, что никто не заметит.

Характеризационные тесты: зафиксировать то, что есть, а не то, что должно быть

Характеризационный тест (characterization test) отличается от обычного юнит-теста целью. Юнит-тест проверяет, что код делает то, что задумано по спецификации. Характеризационный тест не спрашивает, правильно ли ведёт себя код: он просто записывает текущее поведение как эталон. Это особенно полезно, когда спецификации нет, а автор кода недоступен или не помнит деталей.

// Шаг 1: вызываем метод с несколькими типичными и граничными входами,
// фиксируем, что он реально возвращает сейчас, а не что "должен" по идее
public function testLegacyDiscountCurrentBehavior(): void
{
    $calc = new LegacyDiscountCalculator();

    // Эти значения не придуманы по спецификации,
    // они сняты реальным запуском кода на тестовых данных
    $this->assertSame(0, $calc->apply(100, 0));
    $this->assertSame(90, $calc->apply(100, 10));
    // Граничный случай: отрицательная скидка ведёт себя как ноль.
    // Возможно, это баг, но сначала фиксируем факт, потом чиним осознанно
    $this->assertSame(100, $calc->apply(100, -5));
}

После того как такие тесты покрывают основные ветки, у вас есть сеть безопасности: любое изменение внутренней реализации, которое не трогает зафиксированное поведение, можно делать смело. Если тест показывает баг, вроде странной обработки отрицательной скидки выше, это не повод чинить его прямо сейчас, в том же коммите, где вы наводите порядок. Сначала зафиксировать, потом обсудить с командой, баг это или осознанное поведение, и только затем чинить отдельным изменением с собственным тестом на правильное поведение.

Не пишите характеризационные тесты через чтение кода и угадывание ответа. Запускайте реальный код с реальными данными и записывайте то, что он вернул. Угаданный ответ тестирует ваши представления о коде, а не сам код.

Маленькие шаги: правило бойскаута и атомарные коммиты

Главная причина, по которой рефакторинг легаси выходит из под контроля, это слишком крупные шаги. Разработчик открывает метод на двести строк, видит пять проблем одновременно и начинает чинить все пять за один присест. На середине пути тесты красные, непонятно, какое из пяти изменений всё сломало, а откатить частично уже не получится, потому что правки перемешаны в одном рабочем дереве.

Правило бойскаута («оставь код чище, чем он был») работает только при маленьком масштабе: если вы зашли в файл поправить баг, можно улучшить имя одной переменной или вынести один дублирующийся блок, но не перестраивать архитектуру модуля целиком. Для более серьёзного рефакторинга нужна дисциплина отдельных, маленьких, обратимых шагов.

  • Один коммит, одно поведенческое изменение или один чисто структурный рефакторинг, но не оба вида сразу
  • После каждого шага запускаете полный набор тестов, а не только тот файл, который трогали
  • Если шаг занял больше пятнадцати-двадцати минут, скорее всего он слишком крупный, дробите дальше
  • Коммит должен быть описан так, чтобы через полгода было понятно, что и зачем изменилось

Такой ритм кажется медленным на старте, но на практике экономит время: когда тесты падают после маленького коммита, причина очевидна и находится за секунды. Когда тесты падают после большого коммита на триста строк из пяти несвязанных правок, разбор может занять полдня.

Вы в начале разбораДальше: разбор компромиссов, чек-лист и ответы на частые вопросы.

Strangler fig: заменить модуль без остановки системы

Название приёма пришло из ботаники: фикус-душитель обрастает старое дерево снаружи, постепенно забирает на себя его функции, и в итоге старое дерево отмирает, а новое стоит на его месте, используя тот же ствол как опору. В разработке strangler fig означает то же самое: новая реализация модуля строится рядом со старой, часть трафика или вызовов постепенно переключается на неё, а старая реализация удаляется только тогда, когда новая полностью её заменила и это проверено в проде.

Это противоположность классическому «большому переписыванию», когда команда полгода пишет версию 2.0 в отдельной ветке, а в это время старая система продолжает жить своей жизнью, получает правки и расходится с новой версией всё сильнее. К моменту готовности версии 2.0 она либо не покрывает все сценарии, которые старая система успела накопить, либо требует ещё полгода на догонку.

ШагЧто происходитРиск отката
1Новый модуль пишется рядом со старым, за тем же публичным интерфейсомнулевой: старый код не трогали
2Прокси или фича-флаг направляет часть вызовов на новую реализациюнизкий: флаг выключается одной правкой
3Доля трафика на новой реализации растёт, старая остаётся запасным путёмнизкий: мониторинг ловит расхождение
4Старый модуль удаляется, когда новый отработал нужный срок без инцидентовминимальный: решение принято на данных

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

Как выбрать, что рефакторить первым

Легаси-систем с одинаково грязным кодом не существует: в реальном проекте всегда есть модули, которые меняют раз в год, и модули, которые трогают каждую неделю. Рефакторить стоит там, где пересекаются высокая частота изменений и высокая стоимость ошибки, а не там, где код просто визуально неприятен. Красивый, но стабильный модуль, который никто не трогал два года, может спокойно жить в текущем виде.

  1. Выписать модули, в которые команда чаще всего заходит править баги или добавлять фичи
  2. Для каждого отметить, есть ли тесты и насколько уверенно команда понимает его поведение
  3. Начать с пересечения «часто меняем» и «боимся трогать», это даёт максимальную отдачу от вложенного времени

Коммуникация с командой во время долгого рефакторинга

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

Практическое решение: завести видимый список модулей в процессе миграции, доступный всей команде, с коротким статусом по каждому и ссылкой на задачу. Это снимает необходимость переспрашивать в чате, что происходит с конкретным куском системы, и защищает от ситуации, когда два разработчика параллельно чинят один и тот же баг в двух разных версиях одного модуля.

Рефакторинг легаси не задача на один спринт, а постоянная практика. Характеризационные тесты снимают страх неизвестности, маленькие шаги делают каждое изменение обратимым, а strangler fig позволяет заменить даже крупный модуль без риска остановить систему на время переписывания. Вместе эти три приёма превращают рефакторинг из героического подвига в рутинную, предсказуемую работу, которую можно планировать и оценивать по времени так же, как обычные задачи.

Частые вопросы

Нужно ли сразу покрывать весь модуль характеризационными тестами перед рефакторингом?

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

Что делать, если characterization-тест зафиксировал явный баг?

Зафиксируйте текущее поведение как есть, закоммитьте, затем обсудите с командой или продуктом, баг это или осознанная логика, и чините отдельным изменением со своим тестом на правильное поведение.

Strangler fig подходит только для больших систем?

Нет, приём масштабируется: для одного класса это может быть параллельная реализация метода за тем же интерфейсом, для целого сервиса это может быть прокси на уровне балансировщика нагрузки.

Код, который не стыдно передать

Начните с принципов, а дальше двигайтесь по рубрикам. Каждый разбор заканчивается чек-листом.

Случайный разбор