Принципы разработки Легаси-код: швы, характеризующие тесты и безопасные изменения
0%

Легаси-код: швы, характеризующие тесты и безопасные изменения

Легаси-код: швы, характеризующие тесты и безопасные изменения

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

Определение, которое сделало эту тему обсуждаемой, дал Майкл Физерс в книге Working Effectively with Legacy Code:

Легаси-код — это код без тестов.

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

Отсюда фундаментальная дилемма легаси, ради которой и существует вся техника этой статьи:

Чтобы безопасно менять код, нужны тесты. Чтобы написать тесты, обычно нужно изменить код (он не тестируем как есть).

Разрыв этого круга — примерно 80% работы с унаследованными системами. Механику самих преобразований мы разбирали в статье про рефакторинг, а свойства хороших тестов — в принципах тестирования. Здесь — то, что делают до того, как эти две статьи станут применимы.


1. Общий алгоритм: что делать, когда пришла задача

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


2. Разведка: сначала понять, потом менять

Главная ошибка на входе — сразу начать «улучшать». Легаси-код почти всегда содержит знание, которого нет нигде больше: обходы багов внешних систем, требования регуляторов, договорённости с конкретным клиентом. Джоэл Спольски в Things You Should Never Do описывает это как главный аргумент против переписывания: уродливые ветки в старом коде — это застывшие багфиксы.

Приёмы разведки, которые окупаются за часы:

Черновой рефакторинг (scratch refactoring). Возьмите ветку, которую точно не будете мержить, и агрессивно упрощайте код: удаляйте, переименовывайте, разбивайте функции — только чтобы понять структуру. Затем выбросьте ветку. Ценность в понимании, а не в диффе. Физерс подчёркивает это отдельно: попытка сохранить черновик превращает разведку в рискованное изменение.

Эскиз влияния (effect sketch). Прежде чем менять переменную или метод, нарисуйте, что от него зависит: какие поля, какие возвраты, какие побочные эффекты. Это даёт список того, что должны проверить ваши тесты.

Археология в системе контроля версий. 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) не проверяет правильность — он фиксирует фактическое поведение, включая баги.

Алгоритм Физерса, дословно:

  1. Напишите тест, который вызывает код с реалистичными входами и утверждает заведомо неверный результат (assert result == "?").
  2. Запустите. Тест упадёт и покажет реальное значение.
  3. Впишите реальное значение в ассерт.
  4. Повторите для других входов, пока не покроете интересующее поведение.
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: результат сериализуется в файл, файл один раз проверяется человеком и коммитится, а тест сравнивает вывод с эталоном.

Практические детали, о которых узнают на второй день:

  • Уберите недетерминированность до записи эталонов: даты, случайные идентификаторы, порядок словарей, форматирование чисел. Иначе тест станет флаки и его отключат.
  • Эталоны — не спецификация. Их нельзя читать как документацию, и их обновление должно быть осознанным действием («да, я поменял поведение намеренно»), а не --force-update по привычке.
  • Инструменты: ApprovalTests (много языков), pytest --snapshot-update, jest snapshots, 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. Типичные ошибки

  1. Переписать с нуля. Самый популярный и самый дорогой ответ. Вы выбрасываете застывшие багфиксы и получаете те же баги заново, но уже с двумя системами в проде.
  2. «Сначала покроем всё тестами». Проект на кварталы без видимой пользы; его закроют на середине. Покрывайте вокруг точек изменения.
  3. Рефакторинг без характеризации. Без страховки это не рефакторинг, а изменение поведения вслепую.
  4. Молча «починить» найденную странность. Возможно, это требование. Заведите вопрос, зафиксируйте текущее поведение тестом, обсудите отдельно.
  5. Снапшоты как основной вид тестов. Золотой мастер — страховка на время преобразования, а не долгосрочная спецификация: он не объясняет намерение и обновляется механически.
  6. Мокирование всего подряд. Тест, где реального кода почти не осталось, проверяет мокирование, а не систему.
  7. Гигантский «рефакторинговый» PR. Ревью невозможно, конфликты гарантированы. Микрошаги и отдельные коммиты — единственный масштабируемый режим.
  8. Улучшение того, что никто не трогает. Долг без процентов гасить не нужно; ресурс уходит впустую.
  9. Смешивание рефакторинга и фичи в одном коммите. Откат фичи потащит за собой откат улучшений и наоборот.
  10. Работа в одиночку. Легаси — это в первую очередь дефицит знаний; парное чтение кода и разговор с поддержкой экономят дни.

9. Как это выглядит в проде

  • Правило бойскаута с ограничением: каждый, кто трогает файл, оставляет его немного лучше — но ровно одним улучшением, чтобы диф остался читаемым.
  • Тесты как побочный продукт задач. Покрытие легаси растёт не отдельным проектом, а тем, что каждая задача в опасной зоне приносит характеризующие тесты.
  • Карантин для легаси в CI: отдельный набор правил линтера («новый код — по строгим правилам, старый — по мягким»), запрет на рост числа исключений. Механика — в статье про автоматические проверки.
  • Теневые прогоны перед заменой критичных расчётов: недели сравнения на реальном трафике, прежде чем переключить источник истины.
  • Реестр «здесь драконы»: документ с перечнем опасных модулей, известных странностей и контактов тех, кто в теме. Дешевле, чем каждый раз выяснять заново.
  • Ограничение радиуса: новые функции пишутся за границей легаси (новый модуль, новый сервис), а старое постепенно странгулируется — при этом никто не обещает бизнесу «переписать всё».
  • Учёт результата в терминах бизнеса: «время на изменение в биллинге сократилось с 5 дней до 1» звучит для руководства убедительнее, чем «мы отрефакторили». Про разговор о долге — технический долг.

10. Мини-итог

  • Легаси — код без тестов (Физерс) и код, который страшно менять. Проблема не в возрасте, а в отсутствии обратной связи.
  • Дилемма: тесты требуют изменения кода, изменение требует тестов. Разрывается через швы.
  • Сначала разведка: черновой рефакторинг «на выброс», эскиз влияния, история изменений, карта горячих точек.
  • Характеризующие тесты фиксируют поведение как есть, включая баги; их пишут вокруг точек изменения, а не по всему модулю. Для сложных выходов — golden master.
  • Каталог приёмов разрыва зависимостей позволяет сделать код тестируемым изменением, чья корректность очевидна: Extract Interface, Parameterize Constructor, Extract and Override, Introduce Instance Delegator, Break Out Method Object.
  • Когда трогать опасно — Sprout (новое рядом) и Wrap (новое вокруг). Это не компромисс, а нормальная тактика.
  • Не улучшайте то, что не меняется; улучшайте то, что мешает, и оставляйте после себя тесты.

Источники


Что дальше

Общая черта всех предыдущих статей трека: описанные в них правила держатся на дисциплине конкретных людей. Дисциплина уходит вместе с людьми, а код остаётся. Последняя статья трека — про то, как перевести принципы из области договорённостей в область автоматических проверок: типы, линтеры, архитектурные тесты, проверки совместимости и разумно устроенные quality gates, которые защищают качество, но не парализуют команду.

Принципы, которые проверяет машина: типы, линтеры, архитектурные тесты и quality gates

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов