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

Легаси-код редко пугает своей сложностью. Пугает неизвестность: никто не помнит, зачем здесь эта проверка на 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 сработал: обе реализации должны отдавать одинаковый результат на одних и тех же входах хотя бы некоторое время, и это расхождение должно быть видно. Простой способ проверить это без риска для пользователей: считать результат обоими способами, отдавать пользователю старый, а расхождение логировать и анализировать отдельно, прежде чем переключать реальный трафик на новый код.
Как выбрать, что рефакторить первым
Легаси-систем с одинаково грязным кодом не существует: в реальном проекте всегда есть модули, которые меняют раз в год, и модули, которые трогают каждую неделю. Рефакторить стоит там, где пересекаются высокая частота изменений и высокая стоимость ошибки, а не там, где код просто визуально неприятен. Красивый, но стабильный модуль, который никто не трогал два года, может спокойно жить в текущем виде.
- Выписать модули, в которые команда чаще всего заходит править баги или добавлять фичи
- Для каждого отметить, есть ли тесты и насколько уверенно команда понимает его поведение
- Начать с пересечения «часто меняем» и «боимся трогать», это даёт максимальную отдачу от вложенного времени
Коммуникация с командой во время долгого рефакторинга
Strangler fig и маленькие шаги хорошо работают внутри одного репозитория, но их эффект пропадает, если остальная команда не знает, что рефакторинг идёт. Параллельная реализация модуля требует согласования: кто-то должен знать, что определённый класс сейчас существует в двух версиях, и новые фичи временно нужно добавлять в обе, либо откладывать до завершения миграции. Без этой договорённости новая фича попадает только в старую реализацию, и к моменту переключения на новый модуль она там просто отсутствует.
Практическое решение: завести видимый список модулей в процессе миграции, доступный всей команде, с коротким статусом по каждому и ссылкой на задачу. Это снимает необходимость переспрашивать в чате, что происходит с конкретным куском системы, и защищает от ситуации, когда два разработчика параллельно чинят один и тот же баг в двух разных версиях одного модуля.
Рефакторинг легаси не задача на один спринт, а постоянная практика. Характеризационные тесты снимают страх неизвестности, маленькие шаги делают каждое изменение обратимым, а strangler fig позволяет заменить даже крупный модуль без риска остановить систему на время переписывания. Вместе эти три приёма превращают рефакторинг из героического подвига в рутинную, предсказуемую работу, которую можно планировать и оценивать по времени так же, как обычные задачи.
Частые вопросы
Нужно ли сразу покрывать весь модуль характеризационными тестами перед рефакторингом?
Нет, достаточно покрыть те ветки, которые вы собираетесь менять в ближайшем шаге. Полное покрытие легаси-модуля может занять недели и отложить сам рефакторинг на неопределённый срок.
Что делать, если characterization-тест зафиксировал явный баг?
Зафиксируйте текущее поведение как есть, закоммитьте, затем обсудите с командой или продуктом, баг это или осознанная логика, и чините отдельным изменением со своим тестом на правильное поведение.
Strangler fig подходит только для больших систем?
Нет, приём масштабируется: для одного класса это может быть параллельная реализация метода за тем же интерфейсом, для целого сервиса это может быть прокси на уровне балансировщика нагрузки.



