Легаси-код: швы, характеризующие тесты и безопасные изменения
Весь предыдущий трек описывал, как писать код, который потом будет легко менять. Эта статья — про противоположную ситуацию, в которой большинство инженеров проводит большую часть карьеры: код уже написан, написан не вами, тестов нет, автор уволился, а бизнес просит поменять в нём одну строчку к пятнице.
Определение, которое сделало эту тему обсуждаемой, дал Майкл Физерс в книге Working Effectively with Legacy Code:
Легаси-код — это код без тестов.
Определение намеренно провокационное: по нему код, написанный вчера без тестов, уже легаси, а двадцатилетний код с хорошим покрытием — нет. Логика простая: без тестов вы не можете менять код, зная, что не сломали поведение; значит, любое изменение — ставка. Полезно добавить второй, психологический критерий: легаси — это код, который вы боитесь менять. Страх — точный индикатор отсутствия обратной связи.
Отсюда фундаментальная дилемма легаси, ради которой и существует вся техника этой статьи:
Чтобы безопасно менять код, нужны тесты. Чтобы написать тесты, обычно нужно изменить код (он не тестируем как есть).
Разрыв этого круга — примерно 80% работы с унаследованными системами. Механику самих преобразований мы разбирали в статье про рефакторинг, а свойства хороших тестов — в принципах тестирования. Здесь — то, что делают до того, как эти две статьи станут применимы.
1. Общий алгоритм: что делать, когда пришла задача
на затрагиваемое поведение?"} B -- "да" --> C["Обычный цикл:
рефакторинг + изменение
(см. статью о рефакторинге)"] B -- "нет" --> D["Определить точки изменения
и эскиз влияния"] D --> E{"Можно ли вызвать этот код
в тесте как есть?"} E -- "да" --> F["Написать характеризующие тесты
вокруг точек изменения"] E -- "нет" --> G["Найти шов и разорвать зависимость
минимально возможным изменением"] G --> H{"Изменение ради тестируемости
само по себе рискованно?"} H -- "нет" --> F H -- "да" --> I["Sprout / Wrap:
новое пишем рядом, старое не трогаем"] F --> J["Изменить поведение
маленькими шагами"] I --> J J --> K["Оставить участок чище,
чем нашли: тесты остаются в репозитории"]
Ветка I — важная и часто пропускаемая. Иногда сделать код тестируемым дороже и опаснее, чем
аккуратно добавить новое поведение рядом. Тогда честный ход — не героический рефакторинг, а
локализация: новое пишется в тестируемом виде, старое остаётся нетронутым до тех пор, пока не
появится причина его трогать.
2. Разведка: сначала понять, потом менять
Главная ошибка на входе — сразу начать «улучшать». Легаси-код почти всегда содержит знание, которого нет нигде больше: обходы багов внешних систем, требования регуляторов, договорённости с конкретным клиентом. Джоэл Спольски в Things You Should Never Do описывает это как главный аргумент против переписывания: уродливые ветки в старом коде — это застывшие багфиксы.
Приёмы разведки, которые окупаются за часы:
Черновой рефакторинг (scratch refactoring). Возьмите ветку, которую точно не будете мержить, и агрессивно упрощайте код: удаляйте, переименовывайте, разбивайте функции — только чтобы понять структуру. Затем выбросьте ветку. Ценность в понимании, а не в диффе. Физерс подчёркивает это отдельно: попытка сохранить черновик превращает разведку в рискованное изменение.
Эскиз влияния (effect sketch). Прежде чем менять переменную или метод, нарисуйте, что от него зависит: какие поля, какие возвраты, какие побочные эффекты. Это даёт список того, что должны проверить ваши тесты.
значение возврата, содержимое инвойса,
записанная строка, формат выгрузки"] R4 --> T R5 --> T
Археология в системе контроля версий. git log -p --follow <файл>, git blame, поиск номера
тикета в истории. Часто одна строчка коммита объясняет странное условие лучше, чем неделя чтения
кода. Полезные приёмы поиска по истории — в треке Git.
Карта горячих точек. Пересечение частоты изменений и сложности (см. раздел про метрики) показывает, какие части легаси действительно мешают, а какие можно не трогать никогда. Ключевая мысль: легаси не улучшают целиком — улучшают то, что мешает.
3. Швы: где физически можно вмешаться
Центральное понятие Физерса:
Шов (seam) — место, где можно изменить поведение программы, не редактируя код в этом месте. Точка управления (enabling point) — место, где вы выбираете, какое поведение действует.
Смысл конструкции в том, что для теста нужно подменить окружение (БД, часы, сеть, файловую систему), а редактировать сам легаси-код опасно. Шов даёт легальный способ подмены.
Виды швов различаются моментом, в который происходит подмена:
| Вид шва | Момент подмены | Где встречается | Как выглядит точка управления |
|---|---|---|---|
| Объектный | время выполнения | ООП-языки | параметр конструктора, сеттер, DI-контейнер |
| Функциональный | время выполнения | Go, JS, Python | переменная-функция, аргумент-callback |
| Компоновочный (link) | сборка/линковка | C/C++, Java, .NET | classpath, подмена библиотеки, LD_PRELOAD |
| Препроцессорный | компиляция | C/C++ | #define, условная компиляция |
| Языковой (monkey patch) | импорт/выполнение | Python, Ruby, JS | unittest.mock.patch, подмена атрибута модуля |
Практическое правило выбора: предпочитайте объектные и функциональные швы — они видны в коде, проверяются компилятором и не ломаются от переименований. Monkey patching в тестах кажется бесплатным, но привязывает тест к деталям реализации: любой перенос импорта ломает патч. Это частный случай проблемы «моков, проверяющих устройство, а не поведение», разобранной в принципах тестирования.
Пример превращения нетестируемого кода в тестируемый минимальным изменением — функциональный шов в Go:
// БЫЛО: время и запись в БД зашиты внутрь. Тест невозможен без Postgres и без ожидания.
func ExpireSubscriptions(db *sql.DB) error {
rows, err := db.Query("SELECT id, valid_until FROM subs WHERE active")
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var id string
var until time.Time
if err := rows.Scan(&id, &until); err != nil {
return err
}
if until.Before(time.Now()) { // ← зависимость от часов
if _, err := db.Exec("UPDATE subs SET active = false WHERE id = $1", id); err != nil {
return err
}
}
}
return rows.Err()
}
// СТАЛО: два шва. Часы вынесены в переменную пакета (точка управления — присваивание в тесте),
// работа с хранилищем — за интерфейсом, объявленным потребителем.
var now = time.Now // функциональный шов: в тесте подменяется одной строкой
type subsStore interface {
ActiveSubs(ctx context.Context) ([]Sub, error)
Deactivate(ctx context.Context, id string) error
}
func ExpireSubscriptions(ctx context.Context, store subsStore) error {
subs, err := store.ActiveSubs(ctx)
if err != nil {
return err
}
for _, s := range subs {
if s.ValidUntil.Before(now()) {
if err := store.Deactivate(ctx, s.ID); err != nil {
return err
}
}
}
return nil
}
Изменение выглядит крошечным, но именно оно превращает «тест требует БД и путешествий во времени»
в «тест — три строки в памяти». Обратите внимание: интерфейс subsStore объявлен там, где
используется, — это правило из статьи о
зависимостях.
4. Характеризующие тесты: фиксируем то, что есть
Когда шов найден и код можно вызвать, наступает главный шаг. Характеризующий тест (characterization test) не проверяет правильность — он фиксирует фактическое поведение, включая баги.
Алгоритм Физерса, дословно:
- Напишите тест, который вызывает код с реалистичными входами и утверждает заведомо неверный
результат (
assert result == "?"). - Запустите. Тест упадёт и покажет реальное значение.
- Впишите реальное значение в ассерт.
- Повторите для других входов, пока не покроете интересующее поведение.
import pytest
from legacy.billing import calculate_invoice # 700 строк, автор уволился в 2019
@pytest.mark.parametrize(
"customer_type, amount, months",
[("retail", 1000, 1), ("retail", 1000, 12), ("vip", 1000, 12), ("vip", 0, 1)],
)
def test_characterize_calculate_invoice(customer_type, amount, months):
"""ХАРАКТЕРИЗАЦИЯ, а не спецификация: фиксируем поведение как есть.
Шаг 1 — assert result == "?" ; смотрим, что упало;
шаг 2 — вписываем реальное значение сюда.
"""
result = calculate_invoice(customer_type, amount, months)
assert result == EXPECTED[(customer_type, amount, months)]
EXPECTED = {
("retail", 1000, 1): 1200.0,
("retail", 1000, 12): 13_800.0,
("vip", 1000, 12): 12_960.0,
# Ниже — очевидно некорректное поведение: при нулевой сумме начисляется
# минимальный платёж 150. Фиксируем КАК ЕСТЬ, отдельным тикетом BILL-4471
# выясняем у бизнеса, это баг или требование 2017 года.
("vip", 0, 1): 150.0,
}
Три правила, без которых приём вырождается:
- Не «исправляйте» на ходу. Если тест показал странность, ваша задача — записать её и завести вопрос, а не молча поменять. Возможно, на этой странности кто-то держится (закон Хайрама, см. статью про совместимость).
- Покрывайте вокруг точек изменения, а не весь модуль. Цель — страховка для конкретной правки, а не 100% покрытия легаси (это отдельный проект на кварталы).
- Проверяйте, что тесты вообще что-то ловят. Быстрый способ — намеренно внести дефект
(поменять знак, вернуть ноль) и убедиться, что тест краснеет. Систематический —
мутационное тестирование (
mutmut,pitest,Stryker).
Золотой мастер: когда выходов слишком много
Если функция выдаёт большой сложный результат (HTML, PDF, отчёт, JSON на сотни полей), выписывать ассерты вручную невозможно. Тогда применяют golden master / approval testing: результат сериализуется в файл, файл один раз проверяется человеком и коммитится, а тест сравнивает вывод с эталоном.
остальное принимается как «текущее поведение» Test->>Test: рефакторинг легаси Test->>Gold: сравнить новый вывод с эталоном alt Расхождение Test-->>Test: КРАСНЫЙ: поведение изменилось — откатить шаг else Совпадение Test-->>Test: ЗЕЛЁНЫЙ: рефакторинг безопасен end
Практические детали, о которых узнают на второй день:
- Уберите недетерминированность до записи эталонов: даты, случайные идентификаторы, порядок словарей, форматирование чисел. Иначе тест станет флаки и его отключат.
- Эталоны — не спецификация. Их нельзя читать как документацию, и их обновление должно быть
осознанным действием («да, я поменял поведение намеренно»), а не
--force-updateпо привычке. - Инструменты: ApprovalTests (много языков),
pytest --snapshot-update,jestsnapshots,instaв Rust. - Аналог на проде — теневой прогон: новая реализация выполняется параллельно на реальном трафике, результат отдаётся из старой, расхождения логируются. Это лучший из существующих источников «реалистичных входов».
5. Каталог техник разрыва зависимостей
Основная часть книги Физерса — 24 приёма, позволяющих сделать код тестируемым минимально рискованным изменением. Ниже рабочее подмножество, покрывающее большинство ситуаций.
| Приём | Что делает | Когда применять |
|---|---|---|
Extract Interface |
вводит интерфейс над существующим классом | зависимость от тяжёлого класса, есть DI |
Parameterize Constructor |
добавляет параметр с зависимостью, старый конструктор оставляет | объект сам создаёт зависимость |
Parameterize Method |
то же для метода | зависимость нужна только одному методу |
Extract and Override Call |
выносит проблемный вызов в защищённый метод, тест его переопределяет | нельзя менять конструктор |
Extract and Override Factory Method |
то же для new внутри конструктора |
объект создаётся жёстко |
Subclass and Override Method |
тестовый наследник глушит опасное поведение | быстрый способ обойти сеть/БД |
Introduce Instance Delegator |
заменяет статический вызов на вызов через поле | статические методы и синглтоны |
Expose Static Method |
делает чистый кусок статическим и тестируемым отдельно | логика заперта внутри большого класса |
Break Out Method Object |
превращает длинный метод в класс | метод на 400 строк с кучей локальных переменных |
Adapt Parameter |
оборачивает неудобный тип параметра своим интерфейсом | параметр — тяжёлый фреймворочный объект |
Link Substitution |
подменяет библиотеку на этапе компоновки | нельзя менять исходники вообще |
Два примера, показывающих дух приёмов.
Extract and Override Factory Method — когда конструктор сам создаёт то, чего в тесте быть не должно:
# БЫЛО: конструктор лезет в сеть. Объект невозможно создать в тесте.
class ReportJob:
def __init__(self, config):
self.client = PaymentGatewayClient(config.url, timeout=30) # сеть в конструкторе
self.rows = []
def run(self, period):
self.rows = self.client.fetch(period)
return self._render(self.rows)
# СТАЛО: создание вынесено в переопределяемый метод — изменение на две строки,
# поведение продакшн-кода идентично.
class ReportJob:
def __init__(self, config):
self._config = config
self.client = self._make_client() # ← точка управления
self.rows = []
def _make_client(self): # ← шов
return PaymentGatewayClient(self._config.url, timeout=30)
def run(self, period):
self.rows = self.client.fetch(period)
return self._render(self.rows)
# В ТЕСТЕ: наследник глушит сеть, остальной код исполняется настоящий.
class TestableReportJob(ReportJob):
def __init__(self, config, rows):
self._rows = rows
super().__init__(config)
def _make_client(self):
return FakeGateway(self._rows)
def test_report_renders_totals():
job = TestableReportJob(config=Config(url="unused"), rows=[Row(100), Row(250)])
assert "350" in job.run(period="2026-01")
Приём выглядит некрасиво — и это нормально: это временная конструкция, ступенька к нормальному внедрению зависимости. Физерс прямо пишет, что цель — не идеальный дизайн, а возможность написать первый тест; красоту наводят потом, уже под защитой этого теста.
Introduce Instance Delegator — против статических вызовов, которые нельзя подменить:
// БЫЛО: статический вызов намертво связывает код с реализацией.
public decimal Convert(decimal amount, string to) =>
CurrencyRates.Get(to) * amount; // статический класс, ходит в кэш и сеть
// СТАЛО: статика остаётся (её вызывают из ста мест), но появляется экземплярный
// делегат, который можно подменить в тесте.
public class RateProvider : IRateProvider
{
public virtual decimal Get(string code) => CurrencyRates.Get(code);
}
public class Converter
{
private readonly IRateProvider _rates;
public Converter(IRateProvider rates) => _rates = rates; // точка управления
public decimal Convert(decimal amount, string to) => _rates.Get(to) * amount;
}
Общий принцип всех приёмов: изменение ради тестируемости должно быть настолько маленьким, чтобы его корректность была очевидна при чтении. Добавить параметр с значением по умолчанию, вынести вызов в метод, объявить интерфейс — операции, которые IDE выполняет автоматически и в которых сложно ошибиться. Всё остальное делается уже под тестами.
6. Новые требования: Sprout и Wrap
Иногда правильный ответ — вообще не трогать старый код. Два приёма Физерса для этого случая.
Sprout Method / Sprout Class — новая логика пишется в новом, чистом и покрытом тестами месте, а в легаси добавляется ровно одна строка вызова.
# Легаси-метод на 300 строк. Нужно добавить начисление кэшбэка.
def process_order(order):
... # 180 строк, которые мы не трогаем
total = _legacy_total_calc(order)
... # ещё 100 строк
cashback = calculate_cashback(order, total) # ← ЕДИНСТВЕННАЯ добавленная строка
order.cashback = cashback
...
# Новый код — отдельный модуль, чистая функция, полное покрытие тестами.
def calculate_cashback(order, total: Decimal) -> Decimal:
"""Кэшбэк: 3% для подписчиков, 1% остальным, потолок 500.
Чистая функция: тестируется без БД, без сети, без легаси-контекста.
"""
rate = Decimal("0.03") if order.customer.has_subscription else Decimal("0.01")
return min(total * rate, Decimal("500"))
Что вы получаете: новая функциональность корректна и покрыта; риск изменения легаси близок к нулю; доля «хорошего» кода в системе растёт. Что теряете: код временно живёт в двух стилях. Это осознанная плата, и она почти всегда выгодна.
Wrap Method / Wrap Class — когда новое поведение должно происходить вокруг старого: старый метод переименовывается, а под его именем появляется обёртка, вызывающая старое и новое.
# БЫЛО: def charge(self, order): ...
# СТАЛО:
def charge(self, order):
"""Обёртка: сохраняет исходное поведение и добавляет аудит."""
result = self._charge_legacy(order) # старый метод переименован, тело не тронуто
self._audit.record(order.id, result) # новое поведение — в тестируемом объекте
return result
def _charge_legacy(self, order):
... # ни одной строки не изменено
Wrap Class — то же самое на уровне класса, фактически декоратор: новый класс с тем же интерфейсом оборачивает старый. Это удобная ступенька к Strangler Fig, когда обёрток становится достаточно, чтобы за ними спрятать замену.
7. Тяжёлые случаи
Гигантский класс («божественный объект»). Не пытайтесь разделить его целиком. Найдите «ответственности-кандидаты» (группы полей, используемые вместе), выделите одну — ту, что нужна текущей задаче, — и повторяйте при следующих изменениях. Инструментальный признак группы: матрица «метод × поле», где видны кластеры.
Код, который нельзя запустить локально. Первая цель — не тест, а возможность вообще исполнить кусок кода: скрипт запуска, docker-compose с поднятыми зависимостями, тестовые контейнеры. Пока никто в команде не может выполнить функцию за пределами прода, никакие тесты не появятся.
Легаси, тесно связанное с БД. Не отказывайтесь от интеграционных тестов из идеологии: часто дешевле поднять реальную базу (Testcontainers) и написать характеризующие тесты на SQL-логику, чем ломать зависимости в коде. Стоимость и границы применимости — в интеграционном тестировании.
Нет сборки/непонятно, что деплоится. Сначала восстановите воспроизводимую сборку и идентификацию версии, иначе вы не сможете даже сказать, тот ли код правите. Это же первый шаг для любой миграции по 12 факторам.
Утраченные требования. Источники знания в порядке убывания надёжности: поведение прода (теневые прогоны, логи), история изменений и тикеты, сотрудники поддержки (они знают, как система ведёт себя на самом деле), документация (часто устарела), комментарии в коде (часто врут).
Легаси в чужой предметной области. Когда речь идёт о целой подсистеме, техники этой статьи дополняются стратегическими: антикоррупционный слой, ограниченный контекст, поэтапная странгуляция — см. DDD в legacy.
8. Типичные ошибки
- Переписать с нуля. Самый популярный и самый дорогой ответ. Вы выбрасываете застывшие багфиксы и получаете те же баги заново, но уже с двумя системами в проде.
- «Сначала покроем всё тестами». Проект на кварталы без видимой пользы; его закроют на середине. Покрывайте вокруг точек изменения.
- Рефакторинг без характеризации. Без страховки это не рефакторинг, а изменение поведения вслепую.
- Молча «починить» найденную странность. Возможно, это требование. Заведите вопрос, зафиксируйте текущее поведение тестом, обсудите отдельно.
- Снапшоты как основной вид тестов. Золотой мастер — страховка на время преобразования, а не долгосрочная спецификация: он не объясняет намерение и обновляется механически.
- Мокирование всего подряд. Тест, где реального кода почти не осталось, проверяет мокирование, а не систему.
- Гигантский «рефакторинговый» PR. Ревью невозможно, конфликты гарантированы. Микрошаги и отдельные коммиты — единственный масштабируемый режим.
- Улучшение того, что никто не трогает. Долг без процентов гасить не нужно; ресурс уходит впустую.
- Смешивание рефакторинга и фичи в одном коммите. Откат фичи потащит за собой откат улучшений и наоборот.
- Работа в одиночку. Легаси — это в первую очередь дефицит знаний; парное чтение кода и разговор с поддержкой экономят дни.
9. Как это выглядит в проде
- Правило бойскаута с ограничением: каждый, кто трогает файл, оставляет его немного лучше — но ровно одним улучшением, чтобы диф остался читаемым.
- Тесты как побочный продукт задач. Покрытие легаси растёт не отдельным проектом, а тем, что каждая задача в опасной зоне приносит характеризующие тесты.
- Карантин для легаси в CI: отдельный набор правил линтера («новый код — по строгим правилам, старый — по мягким»), запрет на рост числа исключений. Механика — в статье про автоматические проверки.
- Теневые прогоны перед заменой критичных расчётов: недели сравнения на реальном трафике, прежде чем переключить источник истины.
- Реестр «здесь драконы»: документ с перечнем опасных модулей, известных странностей и контактов тех, кто в теме. Дешевле, чем каждый раз выяснять заново.
- Ограничение радиуса: новые функции пишутся за границей легаси (новый модуль, новый сервис), а старое постепенно странгулируется — при этом никто не обещает бизнесу «переписать всё».
- Учёт результата в терминах бизнеса: «время на изменение в биллинге сократилось с 5 дней до 1» звучит для руководства убедительнее, чем «мы отрефакторили». Про разговор о долге — технический долг.
10. Мини-итог
- Легаси — код без тестов (Физерс) и код, который страшно менять. Проблема не в возрасте, а в отсутствии обратной связи.
- Дилемма: тесты требуют изменения кода, изменение требует тестов. Разрывается через швы.
- Сначала разведка: черновой рефакторинг «на выброс», эскиз влияния, история изменений, карта горячих точек.
- Характеризующие тесты фиксируют поведение как есть, включая баги; их пишут вокруг точек изменения, а не по всему модулю. Для сложных выходов — golden master.
- Каталог приёмов разрыва зависимостей позволяет сделать код тестируемым изменением, чья
корректность очевидна:
Extract Interface,Parameterize Constructor,Extract and Override,Introduce Instance Delegator,Break Out Method Object. - Когда трогать опасно — Sprout (новое рядом) и Wrap (новое вокруг). Это не компромисс, а нормальная тактика.
- Не улучшайте то, что не меняется; улучшайте то, что мешает, и оставляйте после себя тесты.
Источники
- Michael Feathers. Working Effectively with Legacy Code — книга; краткий конспект приёмов — Michael Feathers, Dependency-Breaking Techniques
- Martin Fowler. Refactoring, 2nd ed. — книга и каталог
- Joel Spolsky. Things You Should Never Do, Part I
- Martin Fowler. StranglerFigApplication, BranchByAbstraction
- Emily Bache. The Gilded Rose Refactoring Kata — лучшее упражнение по характеризующим тестам
- ApprovalTests — approval/golden master тестирование для многих языков
- Testcontainers — реальные зависимости в тестах легаси
- Nicolas Carlo. Understand Legacy Code — практический блог с разборами приёмов
- Adam Tornhill. Your Code as a Crime Scene, 2nd ed. — Pragmatic Bookshelf
Что дальше
Общая черта всех предыдущих статей трека: описанные в них правила держатся на дисциплине конкретных людей. Дисциплина уходит вместе с людьми, а код остаётся. Последняя статья трека — про то, как перевести принципы из области договорённостей в область автоматических проверок: типы, линтеры, архитектурные тесты, проверки совместимости и разумно устроенные quality gates, которые защищают качество, но не парализуют команду.
Принципы, которые проверяет машина: типы, линтеры, архитектурные тесты и quality gates