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

Легаси на Python — это обычно не «плохой код», а код без тестов, который приносит деньги. У вас есть модуль на 4000 строк, который считает скидки, никто не помнит его целиком, и любая правка в нём — рулетка. Ниже — стратегия, при которой продакшен остаётся живым, а замеры регрессий делаются автоматически, а не глазами пользователей на следующее утро.
Речь про приложение на Python 3.11+, чаще всего Django или Flask, с базой в PostgreSQL и хоть какой-то CI. Универсальных серебряных пуль нет: рефакторинг легаси — это дисциплина маленьких шагов, а не героический спринт.
Главная ошибка — большое переписывание
Соблазн понятен: код страшный, проще написать заново. На практике переписывание с нуля означает, что вы полгода поддерживаете две системы, а старая продолжает набирать баг-фиксы, которые в новую никто не переносит. Джоэл Спольски называл это «худшей стратегической ошибкой, которую может сделать компания» — и для большинства кодовых баз это до сих пор так.
Легаси-код опасен не потому, что он старый, а потому, что вы не знаете, что именно он делает. Первый шаг рефакторинга — зафиксировать текущее поведение, а не улучшить его.
Поэтому стратегия строится в обратном порядке: сначала защита, потом изменения.
Шаг первый: характеризующие тесты
Характеризующий тест * не проверяет, правильно ли работает функция. Он фиксирует, как она работает сейчас — со всеми багами. Это ваша сеть безопасности: если после рефакторинга тест упал, вы изменили поведение, о котором могли не знать.
Для функции без тестов приём простой: скормите ей реальные входные данные, посмотрите на выход, зашейте этот выход в assert.
# pytest, Python 3.11
# Характеризующий тест: фиксируем ТЕКУЩЕЕ поведение расчёта скидки
from legacy.pricing import calculate_discount
def test_discount_current_behavior():
# Входные данные взяты из реального лога продакшена
order = {"total": 1500, "customer_tier": "gold", "items": 3}
result = calculate_discount(order)
# Мы не знаем, ПРАВИЛЬНО ли это число.
# Мы фиксируем, что функция возвращает именно его СЕЙЧАС.
assert result == 187.5
Если функция дёргает базу или внешний API, изолируйте её через unittest.mock или pytest-фикстуры — сейчас цель не в чистоте, а в воспроизводимости. Когда таких тестов набирается достаточно, чтобы покрыть основные ветки, можно трогать код.
Как найти, что покрывать первым
Не покрывайте всё подряд. Гоняйте coverage вместе с реальным трафиком (например, на стейджинге с копией запросов) и смотрите, какие ветки исполняются чаще. Начинайте с горячих путей — код, который никто не вызывает, рефакторить бессмысленно и рискованно одновременно.
Шаг второй: изоляция изменения (шов)
Фезерс называет точку, где можно подменить поведение без правки самого кода, «швом» (seam). В Python швы делаются легко: подмена импорта, dependency injection через аргумент, monkeypatch в тестах.
Практический приём — обернуть старую функцию новой сигнатурой, не трогая внутренности:
# Было: одна огромная функция с побочными эффектами
# Стало: тонкая обёртка, которую можно тестировать и заменять
def calculate_discount_v2(order: dict) -> float:
"""Новая точка входа. Пока просто делегирует в старую."""
return calculate_discount(order)
Теперь весь новый код зовёт calculate_discount_v2. Внутри неё вы постепенно переносите логику, а старую функцию оставляете как эталон для сравнения. Это подводит к следующему приёму.
Шаг третий: параллельный запуск (branch by abstraction)
Когда переписываете критичный кусок, запускайте старую и новую реализацию одновременно, а расхождения логируйте. Это ловит регрессии на реальном трафике до того, как вы переключитесь.
import logging
logger = logging.getLogger("refactor.pricing")
def calculate_discount_shadow(order: dict) -> float:
old = calculate_discount(order) # эталон, идёт в ответ
try:
new = _calculate_discount_new(order) # новая реализация, проверяем
if abs(old - new) > 0.01:
logger.warning(
"discount mismatch: old=%s new=%s order=%s",
old, new, order,
)
except Exception:
logger.exception("new discount impl failed on %s", order)
return old # пользователь пока получает старый результат
Пару недель на продакшене — и лог покажет, на каких входах реализации расходятся. Часто это те самые edge-кейсы, о которых не было ни строчки документации.
Шаг четвёртый: переключение через флаг
Переключение старой логики на новую делайте feature-флагом, а не деплоем. Так откат — это смена значения в конфиге, а не срочный релиз в три часа ночи. Флаг можно хранить хоть в переменной окружения, хоть в отдельной библиотеке вроде того, что предлагает Django через настройки или сторонние пакеты — конкретную библиотеку выбирайте под свой стек, синтаксис сверяйте по её документации.
Как всё это сочетается
| Приём | Что даёт | Чем платите |
|---|---|---|
| Характеризующие тесты | Ловят регрессии автоматически | Фиксируют и баги тоже |
| Швы / обёртки | Меняете реализацию, не трогая вызовы | Временный слой косвенности |
| Параллельный запуск | Проверка на реальном трафике | Двойная нагрузка, шум в логах |
| Feature-флаг | Мгновенный откат без деплоя | Больше веток кода одновременно |
Инструменты, которые снимают ручную работу
- ruff — быстрый линтер и форматтер, ловит мёртвый код и неиспользуемые импорты; безопасное начало уборки.
- mypy — постепенная типизация. В легаси включайте по модулю через настройки, а не на весь проект сразу, иначе утонете в ошибках.
- coverage — показывает, какие строки реально исполняются; помогает не тратить время на мёртвые ветки.
- vulture — ищет предположительно неиспользуемый код (проверяйте находки вручную, ложные срабатывания бывают).
Где стратегия ломается
Три случая, когда описанное буксует:
- Глобальное состояние и побочные эффекты. Если функция пишет в глобал, шлёт письма и меняет БД по дороге — изолировать её тестом дорого. Сначала выносите побочные эффекты на границы (передавайте зависимости аргументами), потом тестируйте чистую часть.
- Изменения, которые меняют поведение намеренно. Характеризующие тесты защищают от случайных регрессий, но не отличают их от нужного исправления бага. Такие правки делайте отдельным коммитом с явным обновлением теста и записью в changelog.
- Дедлайн на новую фичу. Рефакторинг под давлением бизнеса почти всегда проигрывает. Правило: рефакторьте только тот код, который всё равно трогаете ради задачи. Это «правило бойскаута» в разумной дозе, а не отдельный проект «перепишем всё».
Что делать дальше
Начните с одного горячего модуля: снимите с него coverage на реальном трафике, напишите характеризующие тесты на топ-5 путей, добавьте обёртку-шов. Не переключайте флаг, пока лог расхождений при параллельном запуске не будет чистым хотя бы неделю. В проде смотрите на два показателя: частоту записей о mismatch и время ответа эндпоинта — новая реализация не должна быть медленнее старой без причины. Инвалидация кеша и работа с зависимостями в requirements — отдельные темы, здесь не разбираются.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Michael Feathers. Working Effectively with Legacy Code (описание подхода), pytest — официальная документация
Частые вопросы
С чего начать, если тестов вообще нет и код в одном файле на несколько тысяч строк?
А если после рефакторинга упал характеризующий тест — это ошибка?
У меня старая версия Python, часть примеров не запускается. Что делать?
Параллельный запуск двух реализаций не убьёт производительность на проде?
Грозит ли рефакторинг тем, что я нечаянно сломаю то, о чём не знал?
Может, всё-таки проще переписать модуль с нуля?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.