Безопасные изменения в легаси
Миграция схем и CDC — Пакетная работа, опорные темы БД. Сверка — SQL для тестировщика.
Главное правило: сначала возможность проверить, потом красота кода. Майкл Фезерс: без тестов вы меняете поведение вслепую. Ниже — как строить безопасность на практике.
Назад: понимание системы. Дальше: стратегии замены целых частей.
Рефакторинг в легаси
Рефакторинг — последовательность малых эквивалентных преобразований внутренней структуры без смены внешнего поведения (те же входы → те же выходы для наблюдателя снаружи). В легаси он нужен, чтобы следующая фича или фикс не стоили неделю отладки.
Реинжиниринг — другой масштаб: перестройка системы "в новой форме" с расширением функциональности или устранением системных дефектов, часто после обратного инжиниринга. Его планируют отдельно от точечного рефакторинга; крупный рефакторинг нередко предшествует реинжинирингу. Стратегии замены целых частей — в статье 4.
Типичные приёмы из каталога Фаулера, полезные в легаси:
| Приём | Когда |
|---|---|
| Extract Method | Длинная процедура, дублирование фрагмента |
| Extract Class | Группа полей и методов "тянет" отдельную ответственность |
| Encapsulate Field | Прямой доступ к полям мешает тестам и инвариантам |
| Move Method | Метод чаще использует данные другого объекта |
| Replace Conditional with Polymorphism | Разросшийся switch по типам |
Полный обзор — Методы рефакторинга. Схема БД меняется отдельно — рефакторинг баз данных, не путать с переносом только классов.
Play ITЗагрузка интерактивного демо…
Приёмы IDE (извлечь метод, переименовать, вынести класс) рискованны без сетки тестов. Рабочая последовательность Фезерса: тест → рефакторинг → зелёные тесты.
Принципы
- Микрошаги — один коммит = одно понятное преобразование.
- Один PR — одна цель — рефакторинг структуры отдельно от новой функциональности (YAGNI и техдолг).
- Характеризующие тесты — фиксируют "как сейчас", даже если "сейчас" математически спорно.
- Изоляция — фасад, обёртка, временный API вокруг куска, который трогаете.
- Швы (seams) — места, где в тесте можно подменить зависимость без переписывания половины системы.
Этапы на одну задачу
- Найти точку входа — функция/класс под баг или фичу.
- Написать защитный слой тестов (unit → integration → e2e по необходимости).
- Разорвать зависимости — глобалы,
newвнутри логики, жёсткие пути к файлам. - Привести к тестируемому виду — интерфейсы, чистые функции, Strategy.
- Рефакторить структуру — имена, дубли, размер методов.
- Подключить автопрогон тестов в пайплайне (идея — в демо ниже; детали инфраструктуры — в других главах).
Play ITЗагрузка интерактивного демо…
Инструменты рефакторинга
- IDE — IntelliJ, Visual Studio, VS Code + Roslyn/языковые плагины.
- Анализ запахов — SonarQube, CodeScene, DeepSource.
- Массовые правки — ReSharper, Sourcery (Python), jscodeshift (JS/TS).
Характеризующие тесты (characterization tests)
Характеризующий тест отвечает на "изменилось ли по сравнению с вчера?". Сначала фиксируем факт, потом в отдельном изменении исправляем бизнес-логику с согласованием аналитика.
Код ITЗагрузка примера кода…
После согласования "должно быть 10% с банковским округлением" — меняете реализацию и ожидания теста в одном осознанном PR.
Для больших ответов API удобны golden master / approval-тесты: сохраняете JSON-снимок ответа, при рефакторинге сравниваете diff. Подходит, когда формально описать правила трудно, но "вывод не должен поменяться случайно".
Швы (seams)
Шов — место в программе, где можно подменить поведение без редактирования всего дерева вызовов. Фезерс выделяет типы:
| Тип | Идея | Пример |
|---|---|---|
| Object seam | подмена объекта/интерфейса | внедрить Clock вместо now() |
| Preprocessing seam | #ifdef (редко в app-коде) | тестовая сборка |
| Link seam | другая реализация при линковке | mock .so в C (сложно) |
Object seam — время и случайность
Код без шва (трудно тестировать):
class ReportService {
String filename() {
return "report-" + java.time.LocalDate.now() + ".csv";
}
}
Шов через Clock:
Код ITЗагрузка примера кода…
Object seam — HTTP-клиент
async function syncUser(id: string) {
const res = await fetch(`https://legacy.internal/users/${id}`);
return res.json();
}
type HttpGet = (url: string) => Promise<{ json(): Promise<unknown> }>;
async function syncUserWithSeam(id: string, httpGet: HttpGet) {
const res = await httpGet(`https://legacy.internal/users/${id}`);
return res.json();
}
В тесте подставляете функцию, возвращающую заранее заготовленный JSON.
Приёмы Фезерса — Sprout, Wrap, Scratch
Когда рефакторинг "внутри" слишком рискован:
- Sprout (росток) — новая логика в новом методе/классе; старый код вызывает росток.
- Wrap (обёртка) — старый код остаётся, снаружи тонкая обёртка с новым API.
- Scratch (черновик) — эксперимент в ветке/файле, пока не поняли поведение; в main не мержить до тестов.
Пример Sprout:
function calculateLegacyTotal(order) { /* ... */ }
function calculateTotalWithPromo(order, promoRules) {
const base = calculateLegacyTotal(order);
return applyPromo(base, promoRules);
}
Метод Mikado — разрыв зависимостей
Когда модуль "не вытащить" — всё связано со всем. Mikado:
- Нарисовать цель (например, "вынести Billing в отдельный пакет").
- Попробовать изменение → сборка/тесты падают → записать что мешает как отдельный узел.
- Сначала сделать узлы-листья (мелкие разрывы — интерфейс, тест, перенос класса).
- Повторять, пока цель не станет зелёной.
Это дисциплина пошагового рефакторинга, а не правки на 3000 строк за раз.
Регресс и уровни тестов
Регресс — новая поломка из-за изменения в другом месте. В связанном легаси он лавинообразный.
| Уровень | Когда в легаси |
|---|---|
| Unit | после появления швов и чистых функций |
| Integration | БД, очереди — testcontainers, staging |
| E2E / API | контракт с внешним миром, characterization на HTTP |
| Ручной чек-лист | пока нет автоматизации — лучше явный список, чем надежда на память |
Тест должен быть детерминированным. Flaky-тест хуже отсутствия теста: команда перестаёт верить красному пайплайну.
Если внутренности недоступны — тестируйте через публичный API (REST, CLI, файлы обмена). Это медленнее, но легитимный первый слой.
Подробнее про AAA и структуру unit-тестов — Юнит-тестирование.
Пример — от "простыни" к извлекаемому куску
До (фрагмент):
function processOrder(order) {
let total = order.lines.reduce((s, l) => s + l.price * l.qty, 0);
if (order.customer.isVip) total = Math.floor(total * 0.9);
return total;
}
Шаг 1 — characterization на скидку:
function vipDiscount(total) {
return Math.floor(total * 0.9);
}
function processOrder(order) {
let total = order.lines.reduce((s, l) => s + l.price * l.qty, 0);
if (order.customer.isVip) total = vipDiscount(total);
return total;
}
test('vip discount matches legacy floor', () => {
expect(vipDiscount(100)).toBe(90);
});
Шаг 2 — только после зелёных тестов — имена, налоги, вынос репозитория.
Интеграция в процесс команды
- Ревью легаси-PR: "какие тесты доказывают, что поведение сохранилось?"
- Разделять фикс бага и переписывание модуля по разным PR, где возможно.
- Boy Scout rule: оставить код чуть лучше в границах задачи.
Стратегии, когда менять недостаточно — Strangler или clean room — статья 4.